feat(server): 新增 signed licence 校验工具

This commit is contained in:
2026-03-19 16:16:18 +08:00
parent e3e3caed6a
commit e5fed81db5
2 changed files with 126 additions and 0 deletions

View 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')
})
})

View 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)
}