feat(server): 新增 signed licence 校验工具
This commit is contained in:
32
apps/server/src/server/licence.test.ts
Normal file
32
apps/server/src/server/licence.test.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { describe, expect, it } from 'bun:test'
|
||||||
|
import { constants, createSign, generateKeyPairSync } from 'node:crypto'
|
||||||
|
import { decodeLicencePayload, isLicenceExpired, verifyAndDecodeLicenceEnvelope } from './licence'
|
||||||
|
|
||||||
|
describe('licence helpers', () => {
|
||||||
|
it('verifies payload signatures and decodes payload JSON', () => {
|
||||||
|
const { privateKey, publicKey } = generateKeyPairSync('rsa', { modulusLength: 2048 })
|
||||||
|
const payloadJson = JSON.stringify({ licence_id: 'LIC-20260319-0025', expire_time: '2027-03-19' })
|
||||||
|
const payload = Buffer.from(payloadJson, 'utf-8').toString('base64')
|
||||||
|
|
||||||
|
const signer = createSign('RSA-SHA256')
|
||||||
|
signer.update(Buffer.from(payload, 'utf-8'))
|
||||||
|
signer.end()
|
||||||
|
|
||||||
|
const signature = signer.sign({ key: privateKey, padding: constants.RSA_PKCS1_PADDING }).toString('base64')
|
||||||
|
const publicKeyBase64 = publicKey.export({ format: 'der', type: 'spki' }).toString('base64')
|
||||||
|
|
||||||
|
expect(verifyAndDecodeLicenceEnvelope({ payload, signature }, publicKeyBase64)).toEqual({
|
||||||
|
licence_id: 'LIC-20260319-0025',
|
||||||
|
expire_time: '2027-03-19',
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
it('treats expire_time as valid through the end of the UTC day', () => {
|
||||||
|
expect(isLicenceExpired('2027-03-19', new Date('2027-03-19T23:59:59.999Z'))).toBe(false)
|
||||||
|
expect(isLicenceExpired('2027-03-19', new Date('2027-03-20T00:00:00.000Z'))).toBe(true)
|
||||||
|
})
|
||||||
|
|
||||||
|
it('rejects malformed payloads', () => {
|
||||||
|
expect(() => decodeLicencePayload('not-base64')).toThrow('payload must be valid Base64')
|
||||||
|
})
|
||||||
|
})
|
||||||
94
apps/server/src/server/licence.ts
Normal file
94
apps/server/src/server/licence.ts
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
import { rsaVerifySignature } from '@furtherverse/crypto'
|
||||||
|
import { z } from 'zod'
|
||||||
|
|
||||||
|
const BASE64_PATTERN = /^(?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?$/
|
||||||
|
const DATE_PATTERN = /^(\d{4})-(\d{2})-(\d{2})$/
|
||||||
|
|
||||||
|
export const licenceEnvelopeSchema = z.object({
|
||||||
|
payload: z.string().min(1).max(8192).describe('Base64 编码的 licence payload 原文'),
|
||||||
|
signature: z.string().min(1).max(8192).describe('对 payload 字符串 UTF-8 字节做 SHA256withRSA 后得到的 Base64 签名'),
|
||||||
|
})
|
||||||
|
|
||||||
|
export const licencePayloadSchema = z
|
||||||
|
.object({
|
||||||
|
licence_id: z.string().min(1).describe('验签通过后的 licence 标识'),
|
||||||
|
expire_time: z
|
||||||
|
.string()
|
||||||
|
.regex(DATE_PATTERN, 'expire_time must use YYYY-MM-DD')
|
||||||
|
.describe('授权到期日,格式为 YYYY-MM-DD(按 UTC 自然日末尾失效)'),
|
||||||
|
})
|
||||||
|
.loose()
|
||||||
|
|
||||||
|
export type LicenceEnvelope = z.infer<typeof licenceEnvelopeSchema>
|
||||||
|
export type LicencePayload = z.infer<typeof licencePayloadSchema>
|
||||||
|
|
||||||
|
const decodeBase64 = (value: string, fieldName: string): Buffer => {
|
||||||
|
if (!BASE64_PATTERN.test(value)) {
|
||||||
|
throw new Error(`${fieldName} must be valid Base64`)
|
||||||
|
}
|
||||||
|
|
||||||
|
return Buffer.from(value, 'base64')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parseUtcDate = (value: string): Date => {
|
||||||
|
const match = DATE_PATTERN.exec(value)
|
||||||
|
if (!match) {
|
||||||
|
throw new Error('expire_time must use YYYY-MM-DD')
|
||||||
|
}
|
||||||
|
|
||||||
|
const [, yearText, monthText, dayText] = match
|
||||||
|
const year = Number(yearText)
|
||||||
|
const month = Number(monthText)
|
||||||
|
const day = Number(dayText)
|
||||||
|
const parsed = new Date(Date.UTC(year, month - 1, day))
|
||||||
|
|
||||||
|
if (
|
||||||
|
Number.isNaN(parsed.getTime()) ||
|
||||||
|
parsed.getUTCFullYear() !== year ||
|
||||||
|
parsed.getUTCMonth() !== month - 1 ||
|
||||||
|
parsed.getUTCDate() !== day
|
||||||
|
) {
|
||||||
|
throw new Error('expire_time is not a valid calendar date')
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsed
|
||||||
|
}
|
||||||
|
|
||||||
|
export const isLicenceExpired = (expireTime: string, now = new Date()): boolean => {
|
||||||
|
const expireDate = parseUtcDate(expireTime)
|
||||||
|
const expiresAt = Date.UTC(expireDate.getUTCFullYear(), expireDate.getUTCMonth(), expireDate.getUTCDate() + 1)
|
||||||
|
|
||||||
|
return now.getTime() >= expiresAt
|
||||||
|
}
|
||||||
|
|
||||||
|
export const decodeLicencePayload = (payloadBase64: string): LicencePayload => {
|
||||||
|
const decodedJson = decodeBase64(payloadBase64, 'payload').toString('utf-8')
|
||||||
|
|
||||||
|
let rawPayload: unknown
|
||||||
|
try {
|
||||||
|
rawPayload = JSON.parse(decodedJson)
|
||||||
|
} catch {
|
||||||
|
throw new Error('payload must decode to valid JSON')
|
||||||
|
}
|
||||||
|
|
||||||
|
const parsedPayload = licencePayloadSchema.safeParse(rawPayload)
|
||||||
|
if (!parsedPayload.success) {
|
||||||
|
throw new Error(z.prettifyError(parsedPayload.error))
|
||||||
|
}
|
||||||
|
|
||||||
|
return parsedPayload.data
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyLicenceEnvelopeSignature = (envelope: LicenceEnvelope, publicKeyBase64: string): void => {
|
||||||
|
const signatureBytes = decodeBase64(envelope.signature, 'signature')
|
||||||
|
const isValid = rsaVerifySignature(Buffer.from(envelope.payload, 'utf-8'), signatureBytes, publicKeyBase64)
|
||||||
|
|
||||||
|
if (!isValid) {
|
||||||
|
throw new Error('licence signature is invalid')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const verifyAndDecodeLicenceEnvelope = (envelope: LicenceEnvelope, publicKeyBase64: string): LicencePayload => {
|
||||||
|
verifyLicenceEnvelopeSignature(envelope, publicKeyBase64)
|
||||||
|
return decodeLicencePayload(envelope.payload)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user