flnxi-Wu/license-server/lib/module-pull-token.ts
2026-06-02 19:25:48 +08:00

156 lines
4.3 KiB
TypeScript

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;
}
}