From fc5c4fefe63be05e3568cf696571297106cba6be Mon Sep 17 00:00:00 2001 From: WuYongJie Date: Tue, 26 May 2026 15:33:14 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=A0=E5=85=A5=E8=AE=B8=E5=8F=AF=E8=AF=81?= =?UTF-8?q?=E8=AF=B7=E6=B1=82=E7=AD=BE=E5=90=8D=E9=98=B2=E9=87=8D=E6=94=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 2 + .../activate-free/__tests__/route.test.ts | 9 ++ app/api/license/activate-free/route.ts | 14 +- .../license/activate/__tests__/route.test.ts | 8 ++ app/api/license/activate/route.ts | 12 +- app/api/license/heartbeat/route.ts | 14 +- app/api/license/verify/route.ts | 12 +- docker-compose.prod.yml | 3 + docs/deployment/部署说明.md | 4 + docs/商业化地基排查问题清单_20260524.md | 8 +- docs/许可证服务接入说明.md | 10 +- .../license-request-signature.test.ts | 63 +++++++++ lib/license/request-signature.ts | 79 +++++++++++ lib/license/types.ts | 12 +- .../license/[projectCode]/activate/route.ts | 9 ++ .../license/[projectCode]/heartbeat/route.ts | 9 ++ .../[projectCode]/register-free/route.ts | 9 ++ .../api/license/[projectCode]/verify/route.ts | 9 ++ license-server/docker-compose.yml | 1 + .../lib/__tests__/request-signature.test.ts | 128 +++++++++++++++++ license-server/lib/request-signature.ts | 133 ++++++++++++++++++ license-server/lib/types.ts | 14 +- 22 files changed, 529 insertions(+), 33 deletions(-) create mode 100644 lib/__tests__/license-request-signature.test.ts create mode 100644 lib/license/request-signature.ts create mode 100644 license-server/lib/__tests__/request-signature.test.ts create mode 100644 license-server/lib/request-signature.ts diff --git a/.env.example b/.env.example index 63e864f..7aab866 100644 --- a/.env.example +++ b/.env.example @@ -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. diff --git a/app/api/license/activate-free/__tests__/route.test.ts b/app/api/license/activate-free/__tests__/route.test.ts index 3ccb7b8..c26f453 100644 --- a/app/api/license/activate-free/__tests__/route.test.ts +++ b/app/api/license/activate-free/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/license/activate-free/route.ts b/app/api/license/activate-free/route.ts index fda638d..d9ffd66 100644 --- a/app/api/license/activate-free/route.ts +++ b/app/api/license/activate-free/route.ts @@ -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) { diff --git a/app/api/license/activate/__tests__/route.test.ts b/app/api/license/activate/__tests__/route.test.ts index ba33440..47e54c7 100644 --- a/app/api/license/activate/__tests__/route.test.ts +++ b/app/api/license/activate/__tests__/route.test.ts @@ -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 () => { diff --git a/app/api/license/activate/route.ts b/app/api/license/activate/route.ts index 65d7a71..b2f9f76 100644 --- a/app/api/license/activate/route.ts +++ b/app/api/license/activate/route.ts @@ -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(); diff --git a/app/api/license/heartbeat/route.ts b/app/api/license/heartbeat/route.ts index cf8f06f..5aa5ce8 100644 --- a/app/api/license/heartbeat/route.ts +++ b/app/api/license/heartbeat/route.ts @@ -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), }); // 检查是否被吊销 diff --git a/app/api/license/verify/route.ts b/app/api/license/verify/route.ts index 55d2f57..623d6bd 100644 --- a/app/api/license/verify/route.ts +++ b/app/api/license/verify/route.ts @@ -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(); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index 398b018..d4a44b4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -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:-} diff --git a/docs/deployment/部署说明.md b/docs/deployment/部署说明.md index f876c60..83b3e97 100644 --- a/docs/deployment/部署说明.md +++ b/docs/deployment/部署说明.md @@ -64,6 +64,7 @@ NEXTAUTH_SECRET= S3_BUCKET=flnxi-materials LICENSE_SERVER_URL=https://your-license-server.example.com LICENSE_PROJECT_CODE=flnxi-sucai +LICENSE_REQUEST_SIGNING_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` 响应 diff --git a/docs/商业化地基排查问题清单_20260524.md b/docs/商业化地基排查问题清单_20260524.md index 3733bf7..edc3953 100644 --- a/docs/商业化地基排查问题清单_20260524.md +++ b/docs/商业化地基排查问题清单_20260524.md @@ -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 免费版激活保存空签名,后续读取疑似失败(已修复) diff --git a/docs/许可证服务接入说明.md b/docs/许可证服务接入说明.md index 967c6d2..81e018f 100644 --- a/docs/许可证服务接入说明.md +++ b/docs/许可证服务接入说明.md @@ -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` 是否仍被旧地址或旧接口调用 diff --git a/lib/__tests__/license-request-signature.test.ts b/lib/__tests__/license-request-signature.test.ts new file mode 100644 index 0000000..c877c03 --- /dev/null +++ b/lib/__tests__/license-request-signature.test.ts @@ -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'); + }); +}); diff --git a/lib/license/request-signature.ts b/lib/license/request-signature.ts new file mode 100644 index 0000000..b902f8a --- /dev/null +++ b/lib/license/request-signature.ts @@ -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 = {}; + for (const key of Object.keys(value as Record).sort()) { + if (key === 'signature') continue; + const item = (value as Record)[key]; + if (typeof item !== 'undefined') { + result[key] = canonicalize(item); + } + } + return result; + } + + return String(value); +} + +function createSignature(action: string, projectCode: string, payload: Record): string { + const signingPayload = { + action, + projectCode, + payload: canonicalize(payload), + }; + + return crypto + .createHmac(SIGNATURE_ALGORITHM, getRequestSigningSecret()) + .update(JSON.stringify(signingPayload)) + .digest('hex'); +} + +export function buildSignedLicenseRequest>( + 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), + }; +} diff --git a/lib/license/types.ts b/lib/license/types.ts index ae9d6cb..04b9674 100644 --- a/lib/license/types.ts +++ b/lib/license/types.ts @@ -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: { diff --git a/license-server/app/api/license/[projectCode]/activate/route.ts b/license-server/app/api/license/[projectCode]/activate/route.ts index 26c06a1..27e0cfa 100644 --- a/license-server/app/api/license/[projectCode]/activate/route.ts +++ b/license-server/app/api/license/[projectCode]/activate/route.ts @@ -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) { diff --git a/license-server/app/api/license/[projectCode]/heartbeat/route.ts b/license-server/app/api/license/[projectCode]/heartbeat/route.ts index abdd607..51f8c0d 100644 --- a/license-server/app/api/license/[projectCode]/heartbeat/route.ts +++ b/license-server/app/api/license/[projectCode]/heartbeat/route.ts @@ -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) { diff --git a/license-server/app/api/license/[projectCode]/register-free/route.ts b/license-server/app/api/license/[projectCode]/register-free/route.ts index 3d501c4..86877b6 100644 --- a/license-server/app/api/license/[projectCode]/register-free/route.ts +++ b/license-server/app/api/license/[projectCode]/register-free/route.ts @@ -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) { diff --git a/license-server/app/api/license/[projectCode]/verify/route.ts b/license-server/app/api/license/[projectCode]/verify/route.ts index 628b40a..cc62908 100644 --- a/license-server/app/api/license/[projectCode]/verify/route.ts +++ b/license-server/app/api/license/[projectCode]/verify/route.ts @@ -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) { diff --git a/license-server/docker-compose.yml b/license-server/docker-compose.yml index a67911c..d7a4519 100644 --- a/license-server/docker-compose.yml +++ b/license-server/docker-compose.yml @@ -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: diff --git a/license-server/lib/__tests__/request-signature.test.ts b/license-server/lib/__tests__/request-signature.test.ts new file mode 100644 index 0000000..422cb75 --- /dev/null +++ b/license-server/lib/__tests__/request-signature.test.ts @@ -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 = {}; + for (const key of Object.keys(value as Record).sort()) { + if (key === 'signature') continue; + result[key] = canonicalize((value as Record)[key]); + } + return result; + } + + return String(value); + } + + function buildSignedPayload(action: string, payload: Record) { + 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); + } + }); +}); diff --git a/license-server/lib/request-signature.ts b/license-server/lib/request-signature.ts new file mode 100644 index 0000000..4232133 --- /dev/null +++ b/license-server/lib/request-signature.ts @@ -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(); + +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 = {}; + for (const key of Object.keys(value as Record).sort()) { + if (key === 'signature') continue; + const item = (value as Record)[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 { + 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; + 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 }; +} diff --git a/license-server/lib/types.ts b/license-server/lib/types.ts index d2cd084..2820bee 100644 --- a/license-server/lib/types.ts +++ b/license-server/lib/types.ts @@ -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; } // 心跳响应