加入许可证请求签名防重放

This commit is contained in:
WuYongJie 2026-05-26 15:33:14 +08:00
parent 07192cf779
commit fc5c4fefe6
22 changed files with 529 additions and 33 deletions

View File

@ -8,6 +8,8 @@ NEXTAUTH_URL=https://your-domain.example.com
NEXTAUTH_SECRET=replace-with-strong-secret
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
# Shared HMAC secret between material app and license server (minimum 32 characters)
LICENSE_REQUEST_SIGNING_SECRET=replace-with-shared-license-request-secret
# Versioned deployment images
# Customer delivery must pin the full application image tag. Do not rely on latest.

View File

@ -87,6 +87,8 @@ describe('POST /api/license/activate-free', () => {
beforeEach(() => {
jest.clearAllMocks();
global.fetch = jest.fn();
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai';
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32);
mockVerifyLicenseSignature.mockReturnValue(true);
mockInitializeSystem.mockResolvedValue({
admin: {
@ -113,6 +115,13 @@ describe('POST /api/license/activate-free', () => {
signedFreeLicense.signature
);
expect(mockStartHeartbeat).toHaveBeenCalled();
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body);
expect(fetchBody.timestamp).toEqual(expect.any(Number));
expect(fetchBody.nonce).toMatch(/^[0-9a-f]{32}$/);
expect(fetchBody.signature).toMatch(/^[0-9a-f]{64}$/);
expect(fetchBody.customerName).toBe('测试公司');
expect(fetchBody.contactInfo).toBe('13800000000');
});
it('许可证服务没有返回签名许可证时拒绝激活,避免保存不可读取的本地许可证', async () => {

View File

@ -3,6 +3,7 @@ import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
function maskSecret(value: unknown): string {
if (typeof value !== 'string' || !value) return '****';
@ -71,15 +72,16 @@ export async function POST(request: NextRequest) {
}
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('register-free', {
fingerprint,
customerName,
contactInfo,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('register-free'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
fingerprint,
customerName,
contactInfo,
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
if (!response.ok) {

View File

@ -82,6 +82,8 @@ describe('POST /api/license/activate', () => {
beforeEach(() => {
jest.clearAllMocks()
global.fetch = jest.fn()
process.env.LICENSE_PROJECT_CODE = 'flnxi-sucai'
process.env.LICENSE_REQUEST_SIGNING_SECRET = 'a'.repeat(32)
mockVerifyLicenseSignature.mockReturnValue(true)
mockInitializeSystem.mockResolvedValue({
admin: {
@ -115,6 +117,12 @@ describe('POST /api/license/activate', () => {
expect(setCookieHeader).toContain('license_activated=true')
expect(setCookieHeader).toContain('HttpOnly')
expect(setCookieHeader).toContain('Max-Age=31536000')
const fetchBody = JSON.parse((global.fetch as jest.Mock).mock.calls[0][1].body)
expect(fetchBody.timestamp).toEqual(expect.any(Number))
expect(fetchBody.nonce).toMatch(/^[0-9a-f]{32}$/)
expect(fetchBody.signature).toMatch(/^[0-9a-f]{64}$/)
expect(fetchBody.licenseKey).toBe('ABCD-1234-EFGH-5678')
})
test('环境变量来源的初始管理员密码不应在激活响应中回显', async () => {

View File

@ -3,6 +3,7 @@ import { generateFingerprint } from '@/lib/license/fingerprint-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { saveLicenseLocally, initializeSystem, startHeartbeat } from '@/lib/license/license-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import type { ActivateResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
@ -22,14 +23,15 @@ export async function POST(request: NextRequest) {
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('activate', {
licenseKey,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('activate'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
licenseKey,
fingerprint,
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
const data: ActivateResponse = await response.json();

View File

@ -4,6 +4,7 @@ import { getLocalLicense, updateLocalLicense, clearLocalLicense } from '@/lib/li
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { prisma } from '@/lib/prisma';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import type { HeartbeatResponse } from '@/lib/license/types';
export async function POST(request: NextRequest) {
@ -23,15 +24,16 @@ export async function POST(request: NextRequest) {
]);
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('heartbeat', {
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
});
const response = await fetch(buildLicenseServerLicenseApiUrl('heartbeat'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
usage: { userCount, materialCount },
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
// 检查是否被吊销

View File

@ -8,6 +8,7 @@ import {
} from '@/lib/license/license-service';
import { verifyLicenseSignature } from '@/lib/license/crypto-service';
import { buildLicenseServerLicenseApiUrl } from '@/lib/license/server-config';
import { buildSignedLicenseRequest } from '@/lib/license/request-signature';
import type { VerifyResponse } from '@/lib/license/types';
// 宽限期(7天)
@ -34,14 +35,15 @@ export async function POST(request: NextRequest) {
const fingerprint = await generateFingerprint();
// ✅ 安全改进:代理请求到许可证服务器(隐藏服务器地址)
const signedRequest = buildSignedLicenseRequest('verify', {
instanceId: fingerprint.instanceId,
fingerprint,
});
const response = await fetch(buildLicenseServerLicenseApiUrl('verify'), {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
instanceId: fingerprint.instanceId,
fingerprint,
timestamp: Date.now(),
}),
body: JSON.stringify(signedRequest),
});
const data: VerifyResponse = await response.json();

View File

@ -130,6 +130,9 @@ services:
# install.sh / 部署脚本会写入 NEXTAUTH_URL,例如:http://192.168.41.144:4320
- NEXTAUTH_URL=${NEXTAUTH_URL}
- NEXTAUTH_SECRET=${NEXTAUTH_SECRET:?NEXTAUTH_SECRET is required}
- LICENSE_SERVER_URL=${LICENSE_SERVER_URL:?LICENSE_SERVER_URL is required}
- LICENSE_PROJECT_CODE=${LICENSE_PROJECT_CODE:?LICENSE_PROJECT_CODE is required}
- LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required}
# 初始管理员账号(供 /api/system/init 使用)
- INIT_ADMIN_EMAIL=${INIT_ADMIN_EMAIL:-admin@local.flnxi}
- INIT_ADMIN_PASSWORD=${INIT_ADMIN_PASSWORD:-}

View File

@ -64,6 +64,7 @@ NEXTAUTH_SECRET=<generate-a-strong-random-secret>
S3_BUCKET=flnxi-materials
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
LICENSE_REQUEST_SIGNING_SECRET=<generate-a-shared-license-request-secret>
```
说明:
@ -73,6 +74,7 @@ LICENSE_PROJECT_CODE=flnxi-sucai
- `APP_IMAGE` 必须写完整镜像地址和版本号,不要只写 `latest`。
- `POSTGRES_IMAGE`、`MINIO_IMAGE`、`MINIO_MC_IMAGE` 建议同样固定版本;离线或受控网络交付时,应写成私有 Registry 里的固定版本镜像。
- `LICENSE_PROJECT_CODE` 必须和 `license-server` 中项目表的 `projects.code` 一致。
- `LICENSE_REQUEST_SIGNING_SECRET` 必须和 `license-server` 的同名变量一致,至少 32 字符,用于主应用到验证服务器的请求签名。
- 不要把真实密码、许可证地址、对象存储密钥写入 Git。
### 2.3 HTTP Registry 前置检查
@ -204,6 +206,7 @@ $sshPassword = Read-Host 'SSH密码' -AsSecureString
- [ ] `NEXTAUTH_URL` 与最终访问地址一致
- [ ] `S3_BUCKET`、`S3_PUBLIC_DOMAIN` 口径正确
- [ ] `LICENSE_SERVER_URL` 和 `LICENSE_PROJECT_CODE` 已确认
- [ ] `LICENSE_REQUEST_SIGNING_SECRET` 已配置且与许可证服务一致
- [ ] 已执行与本次改动相关的测试或命令
- [ ] 涉及数据库变更时已有备份和回滚方案
@ -291,6 +294,7 @@ docker exec -i flnxi-postgres pg_restore \
- `LICENSE_SERVER_URL`
- `LICENSE_PROJECT_CODE`
- `LICENSE_REQUEST_SIGNING_SECRET` 是否与许可证服务一致
- 许可证服务是否存在对应项目编码
- 主应用 `/api/license/check`、`/api/license/verify` 响应

View File

@ -397,14 +397,19 @@
- `lib/license/license-service.ts`、`app/api/license/verify/route.ts`:远端验证失败进入本地宽限期前,也会先检查签名许可证里的 `leaseExpiresAt`,避免租约已经过期还继续靠本地宽限期放行。
- `app/api/license/check/route.ts`:恢复 `license_activated` cookie 前会检查租约有效性,不能再靠一份过期本地许可证恢复激活状态。
- `app/api/license/heartbeat/route.ts`:心跳拿到验证服务器续发许可证后会先验签再写入本地缓存。
- `lib/license/request-signature.ts`、`license-server/lib/request-signature.ts`:主应用转发到验证服务器的 `activate / register-free / verify / heartbeat` 请求新增 HMAC 签名、`nonce` 和 5 分钟时间窗口;验证服务器会拒绝无签名、签名错误、过期和重复 `nonce` 请求,降低抓包重放和脚本伪造成本。
- `.env.example`、`docker-compose.prod.yml`、`license-server/docker-compose.yml`:新增并强制要求 `LICENSE_REQUEST_SIGNING_SECRET`,主应用和验证服务器必须使用同一个共享签名密钥,且不能写入 Git。
- `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。
- `lib/__tests__/license-request-signature.test.ts`、`license-server/lib/__tests__/request-signature.test.ts`:新增测试覆盖签名生成、篡改拒绝、重复 `nonce` 拒绝、过期拒绝和缺少服务端共享密钥时拒绝静默降级。
- `lib/__tests__/check-limits.test.ts`:功能检查 helper 已全部改为异步调用底层许可证检查。
验证:
- `npm test -- lib/__tests__/license-service.test.ts lib/__tests__/check-limits.test.ts --runInBand`
- `npm test -- lib/__tests__/license-types.test.ts lib/__tests__/license-service.test.ts app/api/license/check/__tests__/route.test.ts app/api/license/info/__tests__/route.test.ts --runInBand`
- `npm test -- lib/__tests__/license-request-signature.test.ts app/api/license/activate/__tests__/route.test.ts app/api/license/activate-free/__tests__/route.test.ts --runInBand`
- `npm test -- license-server/lib/__tests__/request-signature.test.ts --runInBand`
- `npx tsc --noEmit --project license-server/tsconfig.json`
- `npm run type-check`
@ -412,7 +417,8 @@
- 已完成:受限业务 API 继续通过 `canUseCustomTemplates()`、`canUseDepartmentIsolation()`、`canUseSystemSettings()`、`canUploadMaterial()`、`canCreateUser()` 等 helper 检查;这些 helper 底层不再信旧缓存。
- 已完成:免费版采用更短在线租约,适合主要防破解和防白嫖;付费版采用更长租约,优先避免误伤正常客户,同时仍能限制付费凭证长期滥用。
- 后续增强:吊销、异常设备、时间回拨、验证服务器限流、模块包下载和付费核心文件拆分仍需单独推进;当前本轮先保证过期、降级和租约过期不会因为本地旧缓存继续放行。
- 已完成:主应用到验证服务器的公开许可证请求已有轻量签名和防重放,不再只依赖项目编码、时间戳和硬件指纹。
- 后续增强:吊销、异常设备、时间回拨、验证服务器限流、模块包下载和付费核心文件拆分仍需单独推进;当前本轮先保证过期、降级、租约过期和抓包重放不会因为本地旧缓存或无签名请求继续放行。
### P1-05 免费版激活保存空签名,后续读取疑似失败(已修复)

View File

@ -9,6 +9,7 @@
```env
LICENSE_SERVER_URL=https://your-license-server.example.com
LICENSE_PROJECT_CODE=flnxi-sucai
LICENSE_REQUEST_SIGNING_SECRET=replace-with-same-secret-on-license-server
```
含义:
@ -17,6 +18,8 @@ LICENSE_PROJECT_CODE=flnxi-sucai
许可证服务地址
- `LICENSE_PROJECT_CODE`
当前主应用所属项目编码,必须和 `license-server` 主库中的 `projects.code` 一致
- `LICENSE_REQUEST_SIGNING_SECRET`
主应用和许可证服务共享的 HMAC 签名密钥,至少 32 字符,两边必须一致;不要写入 Git 或对外文档
Node Agent 如果需要版本检查,也必须配置同样两项:
@ -35,6 +38,7 @@ Go Agent 目前走的是另一条现状链路,文档和脚本还没有和 Node
- 当前真实 `LICENSE_SERVER_URL`
- 当前真实 `LICENSE_PROJECT_CODE`
- 主应用和许可证服务是否使用同一个 `LICENSE_REQUEST_SIGNING_SECRET`
- `license-server` 后台是否存在对应项目
- 主应用所在机器到许可证服务的网络连通性
- 证书、域名、反向代理是否匹配当前入口
@ -84,6 +88,7 @@ GET /api/version/{projectCode}/agent
其中:
- `activate`、`activate-free`、`verify`、`heartbeat` 会根据 `LICENSE_PROJECT_CODE` 转发到远端项目维度接口。
- `activate`、`activate-free`、`verify`、`heartbeat` 转发到远端前会加入 `timestamp`、`nonce` 和 HMAC `signature`;远端会拒绝签名缺失、签名错误、过期和重复 `nonce` 请求。
- `check` 用于在本地许可证缓存存在时恢复激活状态,会先读取并验签本地许可证。
- `info` 用于读取当前许可证类型、用量和功能开关。
@ -98,7 +103,7 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
补充:
- 远端 `POST /api/license/{projectCode}/generate` 是付费激活码生成接口,当前要求 `X-API-Key`、`timestamp` 和 `sign`,不应被普通前端直接调用。
- `activate / verify / heartbeat / register-free` 当前主要依赖项目编码、时间戳、硬件指纹和服务端返回许可证签名;文档中说“签名校验”时,默认指项目侧校验服务端返回许可证的签名。
- `activate / verify / heartbeat / register-free` 当前会校验请求 HMAC 签名和服务端返回许可证签名;前者防抓包重放和伪造请求,后者防伪造许可证。
## 5. 后台管理入口
@ -128,7 +133,7 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
1. `license-server` 主库里已存在目标项目,且 `projects.code = LICENSE_PROJECT_CODE`
2. 该项目对应的项目库连接配置正确
3. 主应用 `.env` 已配置 `LICENSE_SERVER_URL` 和 `LICENSE_PROJECT_CODE`
3. 主应用 `.env` 已配置 `LICENSE_SERVER_URL`、`LICENSE_PROJECT_CODE` 和 `LICENSE_REQUEST_SIGNING_SECRET`
4. Node Agent 如启用版本检查,也已配置相同的两项
5. 不再调用任何旧的无项目公开接口
@ -144,5 +149,6 @@ ${LICENSE_SERVER_URL}/api/license/${LICENSE_PROJECT_CODE}/...
如果主应用激活失败,优先检查:
- `LICENSE_PROJECT_CODE` 是否写错
- `LICENSE_REQUEST_SIGNING_SECRET` 是否和许可证服务配置一致
- 目标项目是否已禁用
- `license-server` 是否仍被旧地址或旧接口调用

View File

@ -0,0 +1,63 @@
describe('buildSignedLicenseRequest', () => {
const originalEnv = process.env;
beforeEach(() => {
jest.resetModules();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z'));
process.env = {
...originalEnv,
LICENSE_PROJECT_CODE: 'flnxi-sucai',
LICENSE_REQUEST_SIGNING_SECRET: 'a'.repeat(32),
};
});
afterEach(() => {
jest.useRealTimers();
process.env = originalEnv;
});
it('应该给许可证请求补 timestamp、nonce 和 HMAC 签名', async () => {
const { buildSignedLicenseRequest } = await import('../license/request-signature');
const signedRequest = buildSignedLicenseRequest('verify', {
instanceId: 'instance-1',
fingerprint: {
machineId: 'machine-1',
hostname: 'nas-1',
},
});
expect(signedRequest.timestamp).toBe(1779753600000);
expect(signedRequest.nonce).toMatch(/^[0-9a-f]{32}$/);
expect(signedRequest.signature).toMatch(/^[0-9a-f]{64}$/);
expect(signedRequest.instanceId).toBe('instance-1');
});
it('签名应与对象字段顺序无关', async () => {
const { buildSignedLicenseRequest } = await import('../license/request-signature');
const first = buildSignedLicenseRequest('activate', {
a: 1,
b: 2,
});
const second = buildSignedLicenseRequest('activate', {
b: 2,
a: 1,
});
const firstComparable = { ...first, nonce: 'same-nonce' };
const secondComparable = { ...second, nonce: 'same-nonce' };
expect(Object.keys(firstComparable).sort()).toEqual(Object.keys(secondComparable).sort());
expect(first.signature).toMatch(/^[0-9a-f]{64}$/);
expect(second.signature).toMatch(/^[0-9a-f]{64}$/);
});
it('缺少共享签名密钥时应拒绝构造请求', async () => {
delete process.env.LICENSE_REQUEST_SIGNING_SECRET;
const { buildSignedLicenseRequest } = await import('../license/request-signature');
expect(() => buildSignedLicenseRequest('verify', {})).toThrow('LICENSE_REQUEST_SIGNING_SECRET');
});
});

View File

@ -0,0 +1,79 @@
import * as crypto from 'crypto';
const SIGNATURE_ALGORITHM = 'sha256';
type JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike };
function getRequestSigningSecret(): string {
const secret = process.env.LICENSE_REQUEST_SIGNING_SECRET?.trim();
if (!secret || secret.length < 32) {
throw new Error('LICENSE_REQUEST_SIGNING_SECRET 环境变量未配置或长度不足 32 字符');
}
return secret;
}
function getRequestSigningProjectCode(): string {
const projectCode = process.env.LICENSE_PROJECT_CODE?.trim();
if (!projectCode) {
throw new Error('LICENSE_PROJECT_CODE 环境变量未配置');
}
return projectCode;
}
function canonicalize(value: unknown): JsonLike {
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => canonicalize(item));
}
if (typeof value === 'object') {
const result: Record<string, JsonLike> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
if (key === 'signature') continue;
const item = (value as Record<string, unknown>)[key];
if (typeof item !== 'undefined') {
result[key] = canonicalize(item);
}
}
return result;
}
return String(value);
}
function createSignature(action: string, projectCode: string, payload: Record<string, unknown>): string {
const signingPayload = {
action,
projectCode,
payload: canonicalize(payload),
};
return crypto
.createHmac(SIGNATURE_ALGORITHM, getRequestSigningSecret())
.update(JSON.stringify(signingPayload))
.digest('hex');
}
export function buildSignedLicenseRequest<T extends Record<string, unknown>>(
action: string,
payload: T
): T & { timestamp: number; nonce: string; signature: string } {
const projectCode = getRequestSigningProjectCode();
const signedPayload = {
...payload,
timestamp: Date.now(),
nonce: crypto.randomBytes(16).toString('hex'),
};
return {
...signedPayload,
signature: createSignature(action, projectCode, signedPayload),
};
}

View File

@ -35,8 +35,14 @@ export interface HardwareFingerprint {
instanceId: string;
}
// 许可证服务请求签名字段
export interface LicenseRequestSignatureFields {
nonce: string;
signature: string;
}
// 激活请求
export interface ActivateRequest {
export interface ActivateRequest extends LicenseRequestSignatureFields {
licenseKey: string;
fingerprint: HardwareFingerprint;
timestamp: number;
@ -50,7 +56,7 @@ export interface ActivateResponse {
}
// 验证请求
export interface VerifyRequest {
export interface VerifyRequest extends LicenseRequestSignatureFields {
instanceId: string;
fingerprint: HardwareFingerprint;
timestamp: number;
@ -64,7 +70,7 @@ export interface VerifyResponse {
}
// 心跳请求
export interface HeartbeatRequest {
export interface HeartbeatRequest extends LicenseRequestSignatureFields {
instanceId: string;
fingerprint: HardwareFingerprint;
usage: {

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { signLicense, isValidLicenseKeyFormat } from '@/lib/crypto';
import { getPublicProjectDb } from '@/lib/project-auth';
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
import { z } from 'zod';
@ -29,6 +30,14 @@ export async function POST(
}
const body = await request.json();
const signatureResult = verifyLicenseRequestSignature('activate', projectCode, body);
if (!signatureResult.valid) {
return NextResponse.json(
{ success: false, error: '请求签名无效' },
{ status: signatureResult.response.status }
);
}
const parsed = activateSchema.safeParse(body);
if (!parsed.success) {

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { hashFingerprint, signLicense } from '@/lib/crypto';
import { getPublicProjectDb } from '@/lib/project-auth';
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
import { z } from 'zod';
@ -99,6 +100,14 @@ export async function POST(
}
const body = await request.json();
const signatureResult = verifyLicenseRequestSignature('heartbeat', projectCode, body);
if (!signatureResult.valid) {
return NextResponse.json(
{ acknowledged: false, error: '请求签名无效' },
{ status: signatureResult.response.status }
);
}
const parsed = heartbeatSchema.safeParse(body);
if (!parsed.success) {

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { getPublicProjectDb } from '@/lib/project-auth';
import { signLicense } from '@/lib/crypto';
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
import { generateLicenseConfig, LICENSE_CONFIGS } from '@/lib/types';
import { z } from 'zod';
@ -45,6 +46,14 @@ export async function POST(
}
const body = await request.json().catch(() => ({}));
const signatureResult = verifyLicenseRequestSignature('register-free', projectCode, body);
if (!signatureResult.valid) {
return NextResponse.json(
{ success: false, error: '请求签名无效' },
{ status: signatureResult.response.status }
);
}
const parsed = registerFreeSchema.safeParse(body);
if (!parsed.success) {

View File

@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from 'next/server';
import { signLicense } from '@/lib/crypto';
import { getPublicProjectDb } from '@/lib/project-auth';
import { verifyLicenseRequestSignature } from '@/lib/request-signature';
import { generateLicenseConfig, type HardwareFingerprint } from '@/lib/types';
import { z } from 'zod';
@ -51,6 +52,14 @@ export async function POST(
}
const body = await request.json();
const signatureResult = verifyLicenseRequestSignature('verify', projectCode, body);
if (!signatureResult.valid) {
return NextResponse.json(
{ valid: false, error: '请求签名无效' },
{ status: signatureResult.response.status }
);
}
const parsed = verifySchema.safeParse(body);
if (!parsed.success) {

View File

@ -15,6 +15,7 @@ services:
- RSA_PRIVATE_KEY=${RSA_PRIVATE_KEY:?RSA_PRIVATE_KEY is required}
- RSA_PUBLIC_KEY=${RSA_PUBLIC_KEY:?RSA_PUBLIC_KEY is required}
- AES_SECRET_KEY=${AES_SECRET_KEY:?AES_SECRET_KEY is required}
- LICENSE_REQUEST_SIGNING_SECRET=${LICENSE_REQUEST_SIGNING_SECRET:?LICENSE_REQUEST_SIGNING_SECRET is required}
- ADMIN_INITIAL_PASSWORD=${ADMIN_INITIAL_PASSWORD:?ADMIN_INITIAL_PASSWORD is required}
- NODE_ENV=production
depends_on:

View File

@ -0,0 +1,128 @@
describe('verifyLicenseRequestSignature', () => {
const originalEnv = process.env;
const crypto = require('crypto');
beforeEach(() => {
jest.resetModules();
jest.useFakeTimers();
jest.setSystemTime(new Date('2026-05-26T00:00:00.000Z'));
process.env = {
...originalEnv,
LICENSE_PROJECT_CODE: 'flnxi-sucai',
LICENSE_REQUEST_SIGNING_SECRET: 'a'.repeat(32),
};
});
afterEach(() => {
jest.useRealTimers();
process.env = originalEnv;
});
function canonicalize(value: unknown): unknown {
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => canonicalize(item));
}
if (typeof value === 'object') {
const result: Record<string, unknown> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
if (key === 'signature') continue;
result[key] = canonicalize((value as Record<string, unknown>)[key]);
}
return result;
}
return String(value);
}
function buildSignedPayload(action: string, payload: Record<string, unknown>) {
const signedPayload = {
...payload,
timestamp: Date.now(),
nonce: 'nonce-' + Math.random().toString(16).slice(2),
};
const signingPayload = {
action,
projectCode: 'flnxi-sucai',
payload: canonicalize(signedPayload),
};
return {
...signedPayload,
signature: crypto
.createHmac('sha256', process.env.LICENSE_REQUEST_SIGNING_SECRET)
.update(JSON.stringify(signingPayload))
.digest('hex'),
};
}
it('应该接受签名正确的请求', async () => {
const { verifyLicenseRequestSignature } = await import('../request-signature');
const payload = buildSignedPayload('verify', { instanceId: 'instance-1' });
const result = verifyLicenseRequestSignature('verify', 'flnxi-sucai', payload);
expect(result.valid).toBe(true);
});
it('应该拒绝被篡改的请求', async () => {
const { verifyLicenseRequestSignature } = await import('../request-signature');
const payload = buildSignedPayload('verify', { instanceId: 'instance-1' });
const result = verifyLicenseRequestSignature('verify', 'flnxi-sucai', {
...payload,
instanceId: 'tampered-instance',
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.response.status).toBe(401);
}
});
it('应该拒绝同一个 nonce 重放', async () => {
const { verifyLicenseRequestSignature } = await import('../request-signature');
const payload = buildSignedPayload('heartbeat', { instanceId: 'instance-1' });
expect(verifyLicenseRequestSignature('heartbeat', 'flnxi-sucai', payload).valid).toBe(true);
const replayResult = verifyLicenseRequestSignature('heartbeat', 'flnxi-sucai', payload);
expect(replayResult.valid).toBe(false);
if (!replayResult.valid) {
expect(replayResult.response.status).toBe(409);
}
});
it('应该拒绝过期请求', async () => {
const { verifyLicenseRequestSignature } = await import('../request-signature');
const payload = buildSignedPayload('activate', { licenseKey: 'FLNXI-PRO-TEST' });
jest.setSystemTime(new Date('2026-05-26T00:06:00.000Z'));
const result = verifyLicenseRequestSignature('activate', 'flnxi-sucai', payload);
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.response.status).toBe(401);
}
});
it('缺少服务端共享密钥时应返回 500,避免静默降级', async () => {
delete process.env.LICENSE_REQUEST_SIGNING_SECRET;
const { verifyLicenseRequestSignature } = await import('../request-signature');
const result = verifyLicenseRequestSignature('verify', 'flnxi-sucai', {
timestamp: Date.now(),
nonce: 'nonce',
signature: '00',
});
expect(result.valid).toBe(false);
if (!result.valid) {
expect(result.response.status).toBe(500);
}
});
});

View File

@ -0,0 +1,133 @@
import * as crypto from 'crypto';
import { NextResponse } from 'next/server';
const SIGNATURE_ALGORITHM = 'sha256';
const MAX_CLOCK_SKEW_MS = 5 * 60 * 1000;
const NONCE_TTL_MS = MAX_CLOCK_SKEW_MS;
const nonceCache = new Map<string, number>();
type JsonLike = null | boolean | number | string | JsonLike[] | { [key: string]: JsonLike };
function getRequestSigningSecret(): string | null {
const secret = process.env.LICENSE_REQUEST_SIGNING_SECRET?.trim();
return secret && secret.length >= 32 ? secret : null;
}
function canonicalize(value: unknown): JsonLike {
if (value === null || typeof value === 'boolean' || typeof value === 'number' || typeof value === 'string') {
return value;
}
if (Array.isArray(value)) {
return value.map((item) => canonicalize(item));
}
if (typeof value === 'object') {
const result: Record<string, JsonLike> = {};
for (const key of Object.keys(value as Record<string, unknown>).sort()) {
if (key === 'signature') continue;
const item = (value as Record<string, unknown>)[key];
if (typeof item !== 'undefined') {
result[key] = canonicalize(item);
}
}
return result;
}
return String(value);
}
function createSignature(
secret: string,
action: string,
projectCode: string,
payload: Record<string, unknown>
): string {
const signingPayload = {
action,
projectCode,
payload: canonicalize(payload),
};
return crypto
.createHmac(SIGNATURE_ALGORITHM, secret)
.update(JSON.stringify(signingPayload))
.digest('hex');
}
function safeEqualHex(a: string, b: string): boolean {
if (!/^[0-9a-f]+$/i.test(a) || !/^[0-9a-f]+$/i.test(b)) return false;
const left = Buffer.from(a, 'hex');
const right = Buffer.from(b, 'hex');
if (left.length !== right.length) return false;
return crypto.timingSafeEqual(left, right);
}
function pruneNonceCache(now: number): void {
for (const [key, expiresAt] of nonceCache.entries()) {
if (expiresAt <= now) {
nonceCache.delete(key);
}
}
}
export function verifyLicenseRequestSignature(
action: string,
projectCode: string,
payload: unknown
): { valid: true } | { valid: false; response: NextResponse } {
const secret = getRequestSigningSecret();
if (!secret) {
console.error('[License] LICENSE_REQUEST_SIGNING_SECRET 未配置或长度不足');
return {
valid: false,
response: NextResponse.json({ error: '许可证服务签名配置缺失' }, { status: 500 }),
};
}
if (!payload || typeof payload !== 'object') {
return {
valid: false,
response: NextResponse.json({ error: '请求参数无效' }, { status: 400 }),
};
}
const body = payload as Record<string, unknown>;
const { timestamp, nonce, signature } = body;
if (typeof timestamp !== 'number' || typeof nonce !== 'string' || typeof signature !== 'string') {
return {
valid: false,
response: NextResponse.json({ error: '请求签名参数缺失' }, { status: 401 }),
};
}
const now = Date.now();
if (Math.abs(now - timestamp) > MAX_CLOCK_SKEW_MS) {
return {
valid: false,
response: NextResponse.json({ error: '请求已过期' }, { status: 401 }),
};
}
pruneNonceCache(now);
const nonceKey = `${projectCode}:${nonce}`;
if (nonceCache.has(nonceKey)) {
return {
valid: false,
response: NextResponse.json({ error: '请求已被重复使用' }, { status: 409 }),
};
}
const expected = createSignature(secret, action, projectCode, body);
if (!safeEqualHex(signature, expected)) {
return {
valid: false,
response: NextResponse.json({ error: '请求签名无效' }, { status: 401 }),
};
}
nonceCache.set(nonceKey, now + NONCE_TTL_MS);
return { valid: true };
}

View File

@ -35,8 +35,14 @@ export interface HardwareFingerprint {
instanceId: string;
}
// 许可证服务请求签名字段
export interface LicenseRequestSignatureFields {
nonce: string;
signature: string;
}
// 激活请求
export interface ActivateRequest {
export interface ActivateRequest extends LicenseRequestSignatureFields {
licenseKey: string;
fingerprint: HardwareFingerprint;
timestamp: number;
@ -50,11 +56,10 @@ export interface ActivateResponse {
}
// 验证请求
export interface VerifyRequest {
export interface VerifyRequest extends LicenseRequestSignatureFields {
instanceId: string;
fingerprint: HardwareFingerprint;
timestamp: number;
signature: string;
}
// 验证响应
@ -65,7 +70,7 @@ export interface VerifyResponse {
}
// 心跳请求
export interface HeartbeatRequest {
export interface HeartbeatRequest extends LicenseRequestSignatureFields {
instanceId: string;
fingerprint: HardwareFingerprint;
usage: {
@ -73,7 +78,6 @@ export interface HeartbeatRequest {
materialCount: number;
};
timestamp: number;
signature: string;
}
// 心跳响应