import { createCipheriv, createDecipheriv, createHash, randomBytes } from 'node:crypto'; import { isCanonicalIsoTimestamp } from './accountIconProjection.js'; export interface UserSanctions { bannedUntil?: string; mutedUntil?: string; suspendedUntil?: string; warningCount?: number; flags?: string[]; notes?: 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; iconUpdatedAt?: string; profileIconResetAt?: string; canUseGeneralPicture?: boolean; createdAt?: string; legacyMemberNo?: number; icons?: GatewayUserIconInfo[]; } export interface GatewayUserIconInfo { id: string; picture: string; imageServer: number; createdAt: string; } 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; specialAccess?: { kind: 'OPERATOR' | 'TESTER' | 'RECOVERY' | 'OTHER'; expiresAt: 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.iconUpdatedAt !== undefined && (typeof user.iconUpdatedAt !== 'string' || !isCanonicalIsoTimestamp(user.iconUpdatedAt))) || (user.profileIconResetAt !== undefined && (typeof user.profileIconResetAt !== 'string' || !isCanonicalIsoTimestamp(user.profileIconResetAt))) || (user.canUseGeneralPicture !== undefined && typeof user.canUseGeneralPicture !== 'boolean') || (user.icons !== undefined && (!Array.isArray(user.icons) || user.icons.length > 5 || user.icons.some( (icon) => !icon || typeof icon !== 'object' || typeof icon.id !== 'string' || typeof icon.picture !== 'string' || !Number.isSafeInteger(icon.imageServer) || icon.imageServer < 0 || typeof icon.createdAt !== 'string' || !isCanonicalIsoTimestamp(icon.createdAt) ))) || (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') || (identity.specialAccess !== undefined && (!identity.specialAccess || typeof identity.specialAccess !== 'object' || !['OPERATOR', 'TESTER', 'RECOVERY', 'OTHER'].includes(identity.specialAccess.kind) || (identity.specialAccess.expiresAt !== null && typeof identity.specialAccess.expiresAt !== '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; } };