加入许可证请求签名防重放
This commit is contained in:
parent
07192cf779
commit
fc5c4fefe6
@ -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.
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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 () => {
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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),
|
||||
});
|
||||
|
||||
// 检查是否被吊销
|
||||
|
||||
@ -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();
|
||||
|
||||
@ -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:-}
|
||||
|
||||
@ -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` 响应
|
||||
|
||||
|
||||
@ -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 免费版激活保存空签名,后续读取疑似失败(已修复)
|
||||
|
||||
|
||||
@ -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` 是否仍被旧地址或旧接口调用
|
||||
|
||||
63
lib/__tests__/license-request-signature.test.ts
Normal file
63
lib/__tests__/license-request-signature.test.ts
Normal 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');
|
||||
});
|
||||
});
|
||||
79
lib/license/request-signature.ts
Normal file
79
lib/license/request-signature.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
@ -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: {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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:
|
||||
|
||||
128
license-server/lib/__tests__/request-signature.test.ts
Normal file
128
license-server/lib/__tests__/request-signature.test.ts
Normal 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);
|
||||
}
|
||||
});
|
||||
});
|
||||
133
license-server/lib/request-signature.ts
Normal file
133
license-server/lib/request-signature.ts
Normal 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 };
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
|
||||
// 心跳响应
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user