diff --git a/apps/server/src/server/licence.test.ts b/apps/server/src/server/licence.test.ts new file mode 100644 index 0000000..a0be7b8 --- /dev/null +++ b/apps/server/src/server/licence.test.ts @@ -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') + }) +}) diff --git a/apps/server/src/server/licence.ts b/apps/server/src/server/licence.ts new file mode 100644 index 0000000..eee8c00 --- /dev/null +++ b/apps/server/src/server/licence.ts @@ -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 +export type LicencePayload = z.infer + +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) +}