feat(server): 配置接口接入 licence 验签

This commit is contained in:
2026-03-19 16:16:42 +08:00
parent 84c935d4bd
commit 403eec3e12
2 changed files with 69 additions and 16 deletions

View File

@@ -1,9 +1,18 @@
import { oc } from '@orpc/contract' import { oc } from '@orpc/contract'
import { z } from 'zod' import { z } from 'zod'
import { licenceEnvelopeSchema } from '@/server/licence'
const licenceOutput = z
.object({
licenceId: z.string().describe('验签通过后的 licence 标识'),
expireTime: z.string().describe('授权到期日,格式为 YYYY-MM-DD'),
isExpired: z.boolean().describe('当前 licence 是否已过期(按 UTC 自然日计算)'),
})
.describe('当前已安装 licence 的验证后元数据')
const configOutput = z const configOutput = z
.object({ .object({
licence: z.string().nullable().describe('当前本地 licence未设置时为 null'), licence: licenceOutput.nullable().describe('当前本地已验证 licence 的元数据,未设置时为 null'),
fingerprint: z.string().describe('UX 本机计算得到的设备特征码SHA-256'), fingerprint: z.string().describe('UX 本机计算得到的设备特征码SHA-256'),
hasPlatformPublicKey: z.boolean().describe('是否已配置平台公钥'), hasPlatformPublicKey: z.boolean().describe('是否已配置平台公钥'),
hasPgpPrivateKey: z.boolean().describe('是否已配置 OpenPGP 私钥'), hasPgpPrivateKey: z.boolean().describe('是否已配置 OpenPGP 私钥'),
@@ -12,7 +21,11 @@ const configOutput = z
.meta({ .meta({
examples: [ examples: [
{ {
licence: 'LIC-8F2A-XXXX', licence: {
licenceId: 'LIC-20260319-0025',
expireTime: '2027-03-19',
isExpired: false,
},
fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b', fingerprint: '9a3b7c1d2e4f5a6b8c0d1e2f3a4b5c6d7e8f9a0b1c2d3e4f5a6b7c8d9e0f1a2b',
hasPlatformPublicKey: true, hasPlatformPublicKey: true,
hasPgpPrivateKey: true, hasPgpPrivateKey: true,
@@ -33,7 +46,7 @@ export const get = oc
operationId: 'configGet', operationId: 'configGet',
summary: '读取本机身份配置', summary: '读取本机身份配置',
description: description:
'查询 UX 当前本地身份配置状态。\n\n典型用途页面初始化时检测授权状态、加密前检查平台公钥、签名前检查私钥是否就绪。\n\n返回内容\n- licence当前持久化授权码,未设置时为 null\n- fingerprint设备特征码本机自动计算\n- hasPlatformPublicKey是否已写入平台公钥\n- hasPgpPrivateKey是否已写入 OpenPGP 私钥。', '查询 UX 当前本地身份配置状态。\n\n典型用途页面初始化时检测授权状态、验签前检查平台公钥、签名前检查私钥是否就绪。\n\n返回内容\n- licence当前已验证 licence 的元数据,未设置时为 null\n- fingerprint设备特征码本机自动计算\n- hasPlatformPublicKey是否已写入平台公钥\n- hasPgpPrivateKey是否已写入 OpenPGP 私钥。',
tags: ['Config'], tags: ['Config'],
}) })
.input(z.object({}).describe('空请求体,仅触发读取当前配置')) .input(z.object({}).describe('空请求体,仅触发读取当前配置'))
@@ -46,17 +59,19 @@ export const setLicence = oc
operationId: 'configSetLicence', operationId: 'configSetLicence',
summary: '写入本地 licence', summary: '写入本地 licence',
description: description:
'写入或更新本机持久化 licence。\n\n调用时机设备首次激活、授权码变更、授权修复。\n\n约束与行为\n- 接收 licence 文本\n- fingerprint 由本机自动计算,不允许外部覆盖;\n- 成功后返回最新配置快照,便于前端立即刷新授权状态。', '写入或更新本机持久化 licence。\n\n调用时机设备首次激活、授权码变更、授权修复。\n\n约束与行为\n- 接收 `.lic` 文件内容对应的 JSON 信封,而不是文件上传;\n- 使用已配置的平台公钥对 payload 原始字符串做 SHA256withRSA 验签;\n- 仅在验签通过且 expire_time 未过期时持久化\n- fingerprint 由本机自动计算,不允许外部覆盖;\n- 成功后返回最新配置快照,便于前端立即刷新授权状态。',
tags: ['Config'], tags: ['Config'],
}) })
.input( .input(
z licenceEnvelopeSchema.meta({
.object({ examples: [
licence: z.string().min(1).describe('本地持久化的 licence'), {
}) payload: 'eyJsaWNlbmNlX2lkIjoiTElDLTIwMjYwMzE5LTAwMjUiLCJleHBpcmVfdGltZSI6IjIwMjctMDMtMTkifQ==',
.meta({ signature:
examples: [{ licence: 'LIC-8F2A-XXXX' }], 'aLd+wwpz1W5AS0jgE/IstSNjCAQ5estQYIMqeLXRWMIsnKxjZpCvC8O5q/G5LEBBLJXnbTk8N6IMTUx295nf2HQYlXNtJkWiBeUXQ6/uzs0RbhCeRAWK2Hx4kSsmiEv4AHGLb4ozI2XekTc+40+ApJQYqaWbDu/NU99TmDm3/da1VkKpQxH60BhSQVwBtU67w9Vp3SpWm8y1faQ7ci5WDtJf1JZaS70kPXoGeA5018rPeMFlEzUp10yDlGW6RcrT7Dm+r7zFyrFznLK+evBEvTf9mMGWwZZP3q9vJtC/wFt1t5zNHdkb27cTwc9yyqGMWdelXQAQDnoisn2Jzi06KA==',
}), },
],
}),
) )
.output(configOutput) .output(configOutput)
@@ -92,7 +107,7 @@ export const setPlatformPublicKey = oc
operationId: 'configSetPlatformPublicKey', operationId: 'configSetPlatformPublicKey',
summary: '写入本地平台公钥', summary: '写入本地平台公钥',
description: description:
'写入或更新本机持久化平台公钥Base64 编码 SPKI DER。\n\n调用时机设备授权初始化、平台公钥轮换。\n\n约束与行为\n- 仅接收平台 RSA 公钥文本;\n- 公钥保存在本地,设备授权密文接口会自动读取,无需每次传参\n- 成功后返回最新配置快照,可用于确认 hasPlatformPublicKey 状态。', '写入或更新本机持久化平台公钥Base64 编码 SPKI DER。\n\n调用时机设备授权初始化、平台公钥轮换。\n\n约束与行为\n- 仅接收可解析的平台 RSA 公钥文本;\n- 公钥保存在本地,设备授权密文接口和 licence 验签都会自动读取,无需每次传参;\n- 若平台公钥发生变化,已安装 licence 会被清空,需要重新安装已签名 licence\n- 成功后返回最新配置快照,可用于确认 hasPlatformPublicKey 状态。',
tags: ['Config'], tags: ['Config'],
}) })
.input( .input(

View File

@@ -1,16 +1,25 @@
import { validatePgpPrivateKey } from '@furtherverse/crypto' import { validatePgpPrivateKey, validateRsaPublicKey } from '@furtherverse/crypto'
import { ORPCError } from '@orpc/server' import { ORPCError } from '@orpc/server'
import { isLicenceExpired, verifyAndDecodeLicenceEnvelope } from '@/server/licence'
import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey, setUxPlatformPublicKey } from '@/server/ux-config' import { ensureUxConfig, setUxLicence, setUxPgpPrivateKey, setUxPlatformPublicKey } from '@/server/ux-config'
import { db } from '../middlewares' import { db } from '../middlewares'
import { os } from '../server' import { os } from '../server'
const toConfigOutput = (config: { const toConfigOutput = (config: {
licence: string | null licenceId: string | null
licenceExpireTime: string | null
fingerprint: string fingerprint: string
platformPublicKey: string | null platformPublicKey: string | null
pgpPrivateKey: string | null pgpPrivateKey: string | null
}) => ({ }) => ({
licence: config.licence, licence:
config.licenceId && config.licenceExpireTime
? {
licenceId: config.licenceId,
expireTime: config.licenceExpireTime,
isExpired: isLicenceExpired(config.licenceExpireTime),
}
: null,
fingerprint: config.fingerprint, fingerprint: config.fingerprint,
hasPlatformPublicKey: config.platformPublicKey != null, hasPlatformPublicKey: config.platformPublicKey != null,
hasPgpPrivateKey: config.pgpPrivateKey != null, hasPgpPrivateKey: config.pgpPrivateKey != null,
@@ -22,7 +31,28 @@ export const get = os.config.get.use(db).handler(async ({ context }) => {
}) })
export const setLicence = os.config.setLicence.use(db).handler(async ({ context, input }) => { export const setLicence = os.config.setLicence.use(db).handler(async ({ context, input }) => {
const config = await setUxLicence(context.db, input.licence) const currentConfig = await ensureUxConfig(context.db)
if (!currentConfig.platformPublicKey) {
throw new ORPCError('PRECONDITION_FAILED', {
message: 'Platform public key is not configured. Call config.setPlatformPublicKey first.',
})
}
const payload = verifyAndDecodeLicenceEnvelope(input, currentConfig.platformPublicKey)
if (isLicenceExpired(payload.expire_time)) {
throw new ORPCError('BAD_REQUEST', {
message: 'licence has expired',
})
}
const config = await setUxLicence(context.db, {
payload: input.payload,
signature: input.signature,
licenceId: payload.licence_id,
expireTime: payload.expire_time,
})
return toConfigOutput(config) return toConfigOutput(config)
}) })
@@ -38,6 +68,14 @@ export const setPgpPrivateKey = os.config.setPgpPrivateKey.use(db).handler(async
}) })
export const setPlatformPublicKey = os.config.setPlatformPublicKey.use(db).handler(async ({ context, input }) => { export const setPlatformPublicKey = os.config.setPlatformPublicKey.use(db).handler(async ({ context, input }) => {
try {
validateRsaPublicKey(input.platformPublicKey)
} catch (error) {
throw new ORPCError('BAD_REQUEST', {
message: `Invalid platform public key: ${error instanceof Error ? error.message : 'unable to parse'}`,
})
}
const config = await setUxPlatformPublicKey(context.db, input.platformPublicKey) const config = await setUxPlatformPublicKey(context.db, input.platformPublicKey)
return toConfigOutput(config) return toConfigOutput(config)
}) })