import * as crypto from 'crypto'; export const MODULE_PULL_TOKEN_TTL_MS = 15 * 60 * 1000; export const MODULE_PULL_TOKEN_USERNAME = 'flnxi-module'; export interface ModulePullTokenPayload { tokenType: 'flnxi-module-pull'; projectCode: string; licenseId: string; activationId: string; instanceId: string; moduleId: string; registryHost: string; repository: string; tag: string; digest: string | null; iat: number; exp: number; jti: string; } export interface ModulePullCredential { registryHost: string; repository: string; tag: string; digest: string | null; username: string; pullToken: string; expiresAt: string; } function base64UrlEncode(value: string): string { return Buffer.from(value, 'utf8') .toString('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function base64UrlDecode(value: string): string { const padded = value + '='.repeat((4 - (value.length % 4)) % 4); return Buffer.from(padded.replace(/-/g, '+').replace(/_/g, '/'), 'base64').toString('utf8'); } function getPullTokenSecret(): string { const secret = process.env.MODULE_PULL_TOKEN_SECRET || process.env.LICENSE_REQUEST_SIGNING_SECRET || process.env.AES_SECRET_KEY || ''; if (!secret || secret.length < 32) { throw new Error('MODULE_PULL_TOKEN_SECRET 或 LICENSE_REQUEST_SIGNING_SECRET 未配置或长度不足'); } return secret; } function signPayload(encodedPayload: string): string { return crypto .createHmac('sha256', getPullTokenSecret()) .update(encodedPayload) .digest('base64') .replace(/=/g, '') .replace(/\+/g, '-') .replace(/\//g, '_'); } function isTimingSafeEqual(left: string, right: string): boolean { const leftBuffer = Buffer.from(left); const rightBuffer = Buffer.from(right); return leftBuffer.length === rightBuffer.length && crypto.timingSafeEqual(leftBuffer, rightBuffer); } export function hashModulePullToken(token: string): string { return crypto.createHash('sha256').update(token).digest('hex'); } export function createModulePullCredential(params: { projectCode: string; licenseId: string; activationId: string; instanceId: string; moduleId: string; registryHost: string; repository: string; tag: string; digest: string | null; now?: Date; }): { credential: ModulePullCredential; payload: ModulePullTokenPayload } { const nowMs = params.now?.getTime() ?? Date.now(); const expiresMs = nowMs + MODULE_PULL_TOKEN_TTL_MS; const payload: ModulePullTokenPayload = { tokenType: 'flnxi-module-pull', projectCode: params.projectCode, licenseId: params.licenseId, activationId: params.activationId, instanceId: params.instanceId, moduleId: params.moduleId, registryHost: params.registryHost, repository: params.repository, tag: params.tag, digest: params.digest, iat: Math.floor(nowMs / 1000), exp: Math.floor(expiresMs / 1000), jti: crypto.randomBytes(16).toString('hex'), }; const encodedPayload = base64UrlEncode(JSON.stringify(payload)); const signature = signPayload(encodedPayload); const pullToken = `${encodedPayload}.${signature}`; return { payload, credential: { registryHost: params.registryHost, repository: params.repository, tag: params.tag, digest: params.digest, username: MODULE_PULL_TOKEN_USERNAME, pullToken, expiresAt: new Date(expiresMs).toISOString(), }, }; } export function verifyModulePullToken(token: string, now: Date = new Date()): ModulePullTokenPayload | null { const [encodedPayload, signature] = token.split('.'); if (!encodedPayload || !signature) return null; const expectedSignature = signPayload(encodedPayload); if (!isTimingSafeEqual(signature, expectedSignature)) return null; try { const payload = JSON.parse(base64UrlDecode(encodedPayload)) as ModulePullTokenPayload; if (payload.tokenType !== 'flnxi-module-pull') return null; if ( !payload.projectCode || !payload.licenseId || !payload.activationId || !payload.instanceId || !payload.moduleId || !payload.registryHost || !payload.repository || !payload.tag ) { return null; } if (payload.exp * 1000 <= now.getTime()) return null; return payload; } catch { return null; } }