176 lines
6.4 KiB
TypeScript
176 lines
6.4 KiB
TypeScript
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<string, UserServerRestriction>;
|
|
legacyPenalty?: Record<string, unknown>;
|
|
}
|
|
|
|
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<GameSessionTokenPayload>;
|
|
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<GatewayUserInfo>;
|
|
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<NonNullable<GameSessionTokenPayload['identity']>>;
|
|
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;
|
|
}
|
|
};
|