import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; export interface UserSanctions { bannedUntil?: string; mutedUntil?: string; suspendedUntil?: string; warningCount?: number; flags?: string[]; notes?: string; profileIconResetAt?: string; serverRestrictions?: Record; legacyPenalty?: Record; } export interface UserServerRestriction { blockedFeatures?: string[]; until?: string; reason?: string; notes?: string; } export interface GatewayUserInfo { id: string; username: string; displayName: string; roles: string[]; picture?: string; imageServer?: number; canUseGeneralPicture?: boolean; createdAt?: string; legacyMemberNo?: number; } export interface GameSessionTokenPayload { version: 1; profile: string; issuedAt: string; expiresAt: string; sessionId: string; user: GatewayUserInfo; sanctions: UserSanctions; identity?: { kakaoVerified: boolean; canCreateGeneral: boolean; requiresKakaoVerification: boolean; graceEndsAt: string | null; }; } const toBase64Url = (data: Buffer): string => data.toString('base64url'); const fromBase64Url = (value: string): Buffer => Buffer.from(value, 'base64url'); const buildKey = (secret: string): Buffer => createHash('sha256').update(secret).digest(); export const encryptGameSessionToken = (payload: GameSessionTokenPayload, secret: string): string => { const iv = randomBytes(12); const key = buildKey(secret); const cipher = createCipheriv('aes-256-gcm', key, iv); const plaintext = Buffer.from(JSON.stringify(payload), 'utf8'); const ciphertext = Buffer.concat([cipher.update(plaintext), cipher.final()]); const tag = cipher.getAuthTag(); return `${toBase64Url(iv)}.${toBase64Url(ciphertext)}.${toBase64Url(tag)}`; }; export const parseGameSessionTokenPayload = (value: unknown): GameSessionTokenPayload | null => { if (!value || typeof value !== 'object') { return null; } const payload = value as Partial; if (payload.version !== 1) { return null; } if (typeof payload.profile !== 'string') { return null; } if (typeof payload.issuedAt !== 'string' || typeof payload.expiresAt !== 'string') { return null; } if (typeof payload.sessionId !== 'string') { return null; } if (!payload.user || typeof payload.user !== 'object') { return null; } const user = payload.user as Partial; if ( typeof user.id !== 'string' || typeof user.username !== 'string' || typeof user.displayName !== 'string' || !Array.isArray(user.roles) || (user.picture !== undefined && typeof user.picture !== 'string') || (user.imageServer !== undefined && (!Number.isSafeInteger(user.imageServer) || user.imageServer < 0)) || (user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') || (user.legacyMemberNo !== undefined && (!Number.isSafeInteger(user.legacyMemberNo) || user.legacyMemberNo <= 0)) ) { return null; } if (!payload.sanctions || typeof payload.sanctions !== 'object') { return null; } if (payload.identity !== undefined) { if (!payload.identity || typeof payload.identity !== 'object') { return null; } const identity = payload.identity as Partial>; if ( typeof identity.kakaoVerified !== 'boolean' || typeof identity.canCreateGeneral !== 'boolean' || typeof identity.requiresKakaoVerification !== 'boolean' || (identity.graceEndsAt !== null && typeof identity.graceEndsAt !== 'string') ) { return null; } } return payload as GameSessionTokenPayload; }; export const decryptGameSessionToken = (token: string, secret: string): GameSessionTokenPayload | null => { const parts = token.split('.'); if (parts.length !== 3) { return null; } try { const [ivPart, cipherPart, tagPart] = parts; const iv = fromBase64Url(ivPart); const ciphertext = fromBase64Url(cipherPart); const tag = fromBase64Url(tagPart); const key = buildKey(secret); const decipher = createDecipheriv('aes-256-gcm', key, iv); decipher.setAuthTag(tag); const plaintext = Buffer.concat([decipher.update(ciphertext), decipher.final()]).toString('utf8'); return parseGameSessionTokenPayload(JSON.parse(plaintext)); } catch { return null; } };