checkpoint: secure assets and login flow baseline

This commit is contained in:
Lorenzune
2026-04-23 07:01:09 +02:00
parent f6096371be
commit 237c523f9a
17 changed files with 3573 additions and 694 deletions
+155 -60
View File
@@ -1,5 +1,5 @@
import { GetAssetManager, GetAvatarRenderManager, GetCommunication, GetConfiguration, GetLocalizationManager, GetRoomEngine, GetRoomSessionManager, GetSessionDataManager, GetSoundManager, GetStage, GetTexturePool, GetTicker, HabboWebTools, LegacyExternalInterface, LoadGameUrlEvent, NitroEventType, NitroLogger, NitroVersion, PrepareRenderer } from '@nitrots/nitro-renderer';
import { FC, useCallback, useEffect, useState } from 'react';
import { FC, useCallback, useEffect, useRef, useState } from 'react';
import { GetUIVersion } from './api';
import { Base } from './common';
import { LoadingView } from './components/loading/LoadingView';
@@ -10,13 +10,51 @@ import { useMessageEvent, useNitroEvent } from './hooks';
NitroVersion.UI_VERSION = GetUIVersion();
const preloadUrl = async (url: string): Promise<void> =>
{
if(!url) return;
try
{
const response = await fetch(url, { cache: 'force-cache' });
await response.arrayBuffer();
}
catch {}
};
const preloadImage = (url: string): void =>
{
if(!url) return;
try
{
const image = new Image();
image.decoding = 'async';
image.src = url;
}
catch {}
};
const asStringArray = (value: unknown): string[] =>
{
if(Array.isArray(value)) return value.filter(item => typeof item === 'string');
if(typeof value === 'string' && value.length) return [ value ];
return [];
};
export const App: FC<{}> = props =>
{
const [ isReady, setIsReady ] = useState(false);
const [ errorMessage, setErrorMessage ] = useState('');
const [ homeUrl, setHomeUrl ] = useState('');
const [ showLogin, setShowLogin ] = useState(false);
const [ showLogin, setShowLogin ] = useState(() => !window.NitroConfig?.['sso.ticket']);
const [ isEnteringHotel, setIsEnteringHotel ] = useState(false);
const [ prepareTrigger, setPrepareTrigger ] = useState(0);
const warmupPromiseRef = useRef<Promise<void>>(null);
const rendererPromiseRef = useRef<Promise<any>>(null);
const tickersStartedRef = useRef(false);
const heartbeatIntervalRef = useRef<number>(null);
const showSessionExpired = useCallback(() =>
{
const baseUrl = window.location.origin + '/';
@@ -24,13 +62,15 @@ export const App: FC<{}> = props =>
setErrorMessage('Your session has expired.\nPlease log in again to enter the hotel.');
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
}, []);
const handleAuthenticated = useCallback((ssoTicket: string) =>
{
if(!ssoTicket) return;
window.NitroConfig['sso.ticket'] = ssoTicket;
setShowLogin(false);
GetConfiguration().setValue('sso.ticket', ssoTicket);
setIsEnteringHotel(true);
setErrorMessage('');
setPrepareTrigger(prev => prev + 1);
}, []);
@@ -47,10 +87,89 @@ export const App: FC<{}> = props =>
LegacyExternalInterface.callGame('showGame', parser.url);
});
const startRenderer = useCallback((width: number, height: number) =>
{
if(rendererPromiseRef.current) return rendererPromiseRef.current;
const rawUseBackBuffer = window.NitroConfig?.['renderer.useBackBuffer'];
const useBackBuffer = (rawUseBackBuffer === undefined)
? true
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
rendererPromiseRef.current = PrepareRenderer({
width: Math.floor(width),
height: Math.floor(height),
resolution: window.devicePixelRatio,
autoDensity: true,
backgroundAlpha: 0,
preference: 'webgl',
eventMode: 'none',
failIfMajorPerformanceCaveat: false,
roundPixels: true,
useBackBuffer
});
return rendererPromiseRef.current;
}, []);
const startWarmup = useCallback((width: number, height: number) =>
{
if(warmupPromiseRef.current) return warmupPromiseRef.current;
warmupPromiseRef.current = (async () =>
{
await GetConfiguration().init();
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
startRenderer(width, height).catch(error => NitroLogger.error('[LoginScreen] Renderer warmup failed', error));
const interpolate = (value: string) => GetConfiguration().interpolate(value);
const assetUrls = asStringArray(GetConfiguration().getValue<unknown>('preload.assets.urls')).map(interpolate);
const gamedataUrls = [
...asStringArray(GetConfiguration().getValue<unknown>('external.texts.url')).map(interpolate),
...[
'furnidata.url',
'productdata.url',
'avatar.actions.url',
'avatar.figuredata.url',
'avatar.figuremap.url',
'avatar.effectmap.url'
].map(key => interpolate(GetConfiguration().getValue<string>(key, ''))).filter(Boolean)
];
const loginImages = ((GetConfiguration().getValue<Record<string, unknown>>('loginview', {})?.images) as Record<string, string>) ?? {};
const loginImageUrls = [
loginImages.background,
loginImages.sun,
loginImages.drape,
loginImages.left,
loginImages['right.repeat'],
loginImages.right
].filter(Boolean).map(interpolate);
loginImageUrls.forEach(preloadImage);
gamedataUrls.forEach(url => preloadUrl(url));
await Promise.all(
[
GetAssetManager().downloadAssets(assetUrls),
GetLocalizationManager().init(),
GetAvatarRenderManager().init(),
GetSoundManager().init()
]
);
})();
return warmupPromiseRef.current;
}, [ startRenderer ]);
useEffect(() =>
{
let heartbeatInterval: number = null;
const prepare = async (width: number, height: number) =>
{
try
@@ -58,6 +177,7 @@ export const App: FC<{}> = props =>
if(!window.NitroConfig) throw new Error('NitroConfig is not defined!');
const ssoTicket = window.NitroConfig['sso.ticket'];
if(ssoTicket) GetConfiguration().setValue('sso.ticket', ssoTicket);
if(!ssoTicket || ssoTicket === '')
{
@@ -79,62 +199,29 @@ export const App: FC<{}> = props =>
{
setIsReady(false);
setShowLogin(true);
startWarmup(width, height).catch(error => NitroLogger.error('[LoginScreen] Warmup failed', error));
return;
}
if(configInitError)
{
setHomeUrl(window.location.origin + '/');
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
setIsReady(false);
setShowLogin(false);
return;
}
if(configInitError)
{
setHomeUrl(window.location.origin + '/');
setErrorMessage(`Unable to load renderer-config.json.\n${ String((configInitError as Error)?.message ?? configInitError) }`);
setIsReady(false);
setShowLogin(false);
setIsEnteringHotel(false);
return;
}
showSessionExpired();
return;
}
const rawUseBackBuffer = window.NitroConfig['renderer.useBackBuffer'];
const useBackBuffer = (rawUseBackBuffer === undefined)
? true
: ((rawUseBackBuffer === true) || (rawUseBackBuffer === 'true'));
const renderer = await PrepareRenderer({
width: Math.floor(width),
height: Math.floor(height),
resolution: window.devicePixelRatio,
autoDensity: true,
backgroundAlpha: 0,
preference: 'webgl',
eventMode: 'none',
failIfMajorPerformanceCaveat: false,
roundPixels: true,
useBackBuffer // Keep disabled by default unless explicitly enabled in NitroConfig
});
await GetConfiguration().init();
GetTicker().maxFPS = GetConfiguration().getValue<number>('system.fps.max', 24);
NitroLogger.LOG_DEBUG = GetConfiguration().getValue<boolean>('system.log.debug', true);
NitroLogger.LOG_WARN = GetConfiguration().getValue<boolean>('system.log.warn', false);
NitroLogger.LOG_ERROR = GetConfiguration().getValue<boolean>('system.log.error', false);
NitroLogger.LOG_EVENTS = GetConfiguration().getValue<boolean>('system.log.events', false);
NitroLogger.LOG_PACKETS = GetConfiguration().getValue<boolean>('system.log.packets', false);
const assetUrls = GetConfiguration().getValue<string[]>('preload.assets.urls').map(url => GetConfiguration().interpolate(url)) ?? [];
await Promise.all(
[
GetAssetManager().downloadAssets(assetUrls),
GetLocalizationManager().init(),
GetAvatarRenderManager().init(),
GetSoundManager().init(),
GetSessionDataManager().init(),
GetRoomSessionManager().init()
]
);
const renderer = await startRenderer(width, height);
await startWarmup(width, height);
await GetSessionDataManager().init();
await GetRoomSessionManager().init();
await GetRoomEngine().init();
await GetCommunication().init();
@@ -142,17 +229,25 @@ export const App: FC<{}> = props =>
HabboWebTools.sendHeartBeat();
heartbeatInterval = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
heartbeatIntervalRef.current = window.setInterval(() => HabboWebTools.sendHeartBeat(), 10000);
GetTicker().add(ticker => GetRoomEngine().update(ticker));
GetTicker().add(ticker => renderer.render(GetStage()));
GetTicker().add(ticker => GetTexturePool().run());
if(!tickersStartedRef.current)
{
tickersStartedRef.current = true;
GetTicker().add(ticker => GetRoomEngine().update(ticker));
GetTicker().add(ticker => renderer.render(GetStage()));
GetTicker().add(ticker => GetTexturePool().run());
}
setIsReady(true);
setShowLogin(false);
setIsEnteringHotel(false);
}
catch(err)
{
NitroLogger.error(err);
setIsEnteringHotel(false);
showSessionExpired();
}
};
@@ -161,15 +256,15 @@ export const App: FC<{}> = props =>
return () =>
{
if(heartbeatInterval !== null) window.clearInterval(heartbeatInterval);
if(heartbeatIntervalRef.current !== null) window.clearInterval(heartbeatIntervalRef.current);
};
}, [ prepareTrigger ]);
}, [ prepareTrigger, startWarmup, startRenderer ]);
return (
<Base fit overflow="hidden" className={ !(window.devicePixelRatio % 1) && 'image-rendering-pixelated' }>
{ !isReady && !showLogin &&
{ !isReady && !showLogin && errorMessage.length > 0 &&
<LoadingView isError={ errorMessage.length > 0 } message={ errorMessage } homeUrl={ homeUrl } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } /> }
{ !isReady && showLogin && <LoginView onAuthenticated={ handleAuthenticated } isEntering={ isEnteringHotel } /> }
{ isReady && <MainView /> }
<ReconnectView />
<Base id="draggable-windows-container" />
+41
View File
@@ -0,0 +1,41 @@
import { installSecureFetch, secureUrl } from './secure-assets';
installSecureFetch();
const setBootDebug = (message: string) =>
{
try
{
(window as any).__nitroBootDebug = message;
const secureNode = document.getElementById('nitro-secure-debug');
if(secureNode) secureNode.textContent = `${ secureNode.textContent }\n${ message }`;
}
catch {}
};
setBootDebug('boot: secure fetch installed');
const search = new URLSearchParams(window.location.search);
(window as any).NitroSecureApiUrl = 'https://nitro.slogga.it:2096';
(window as any).NitroConfig = {
'config.urls': [
secureUrl('config', 'renderer-config.json'),
secureUrl('config', 'ui-config.json')
],
'sso.ticket': search.get('sso') || null,
'forward.type': search.get('room') ? 2 : -1,
'forward.id': search.get('room') || 0,
'friend.id': search.get('friend') || 0
};
setBootDebug('boot: NitroConfig assigned');
import('./index')
.then(() => setBootDebug('boot: app bundle imported'))
.catch(error =>
{
setBootDebug(`boot: import failed ${ error?.message || error }`);
throw error;
});
+3 -8
View File
@@ -11,11 +11,9 @@ export const LoadingView: FC<LoadingViewProps> = props => {
const { isError = false, message = '', homeUrl = '' } = props;
return (
<Column fullHeight position="relative" className="relative z-[100] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Column fullHeight position="fixed" className="fixed inset-0 z-[2147483000] bg-[radial-gradient(#1d1a24,#003a6b)]">
<Base fullHeight className="container h-100">
<Column fullHeight alignItems="center" justifyContent="center">
{ !isError &&
<Base className="absolute inset-0 m-auto w-[84px] h-[84px] [zoom:1.5] [image-rendering:pixelated] bg-[url('@/assets/images/loading/loading.gif')] bg-no-repeat bg-left-top" /> }
<Base className="absolute top-[20px] left-[20px] z-[2] w-[150px] h-[100px] bg-[url('@/assets/images/notifications/nitro_v3.png')] bg-no-repeat bg-left-top" />
{ isError && (message && message.length) ?
<Column alignItems="center" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 max-w-[80%]" gap={ 2 }>
@@ -31,13 +29,10 @@ export const LoadingView: FC<LoadingViewProps> = props => {
</a>
}
</Column>
:
<Text fontSizeCustom={32} variant="white" className="absolute bottom-[20px] left-1/2 z-[3] -translate-x-1/2 [text-shadow:0px_4px_4px_rgba(0,0,0,0.25)]">
The hotel is loading ...
</Text>
: null
}
</Column>
</Base>
</Column>
);
};
};
+50 -12
View File
@@ -16,6 +16,13 @@ const LOCK_KEY = 'nitro.login.lock';
const MAX_ATTEMPTS = 5;
const LOCK_WINDOW_MS = 60_000;
const LOCK_DURATION_MS = 2 * 60_000;
const DEFAULT_LOGIN_IMAGES: Record<string, string> = {
background: 'https://hotel.slogga.it/client/nitro/images/reception/background_gradient_apr25.png',
'background.colour': '#6eadc8',
drape: 'https://hotel.slogga.it/client/nitro/images/reception/drape.png',
left: 'https://hotel.slogga.it/client/nitro/images/reception/mute_reception_backdrop_left.png',
right: 'https://hotel.slogga.it/client/nitro/images/reception/background_right.png'
};
type AttemptState = { attempts: number; firstAt: number; lockedUntil: number };
@@ -39,9 +46,10 @@ const writeLock = (state: AttemptState) =>
export interface LoginViewProps
{
onAuthenticated: (ssoTicket: string) => void;
isEntering?: boolean;
}
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
export const LoginView: FC<LoginViewProps> = ({ onAuthenticated, isEntering = false }) =>
{
const [ mode, setMode ] = useState<DialogMode>('login');
const [ username, setUsername ] = useState('');
@@ -55,7 +63,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
const [ loginPingingServer, setLoginPingingServer ] = useState(false);
const submitTimeRef = useRef(0);
const loginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
const configuredLoginImages: Record<string, string> = ((GetConfigurationValue<Record<string, unknown>>('loginview', {})?.['images']) as Record<string, string>) ?? {};
const loginImages: Record<string, string> = { ...DEFAULT_LOGIN_IMAGES, ...configuredLoginImages };
const backgroundColor = (loginImages['background.colour'] || GetConfigurationValue<string>('login_background.colour', '#6eadc8'));
const background = interpolate(loginImages['background'] || GetConfigurationValue<string>('login_background', ''));
@@ -64,6 +73,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
const left = interpolate(loginImages['left'] || GetConfigurationValue<string>('login_left', ''));
const rightRepeat = interpolate(loginImages['right.repeat'] || GetConfigurationValue<string>('login_right.repeat', ''));
const right = interpolate(loginImages['right'] || GetConfigurationValue<string>('login_right', ''));
const loginImageUrls = useMemo(() => [ background, sun, drape, left, rightRepeat, right ].filter(Boolean), [ background, sun, drape, left, rightRepeat, right ]);
const [ loginImagesVersion, setLoginImagesVersion ] = useState(0);
const loginUrl = GetConfigurationValue<string>('login.endpoint', '/api/auth/login');
const registerUrl = GetConfigurationValue<string>('login.register.endpoint', '/api/auth/register');
const forgotUrl = GetConfigurationValue<string>('login.forgot.endpoint', '/api/auth/forgot-password');
@@ -86,6 +97,30 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
if(mode === 'login') resetLoginTurnstile();
}, [ mode, resetLoginTurnstile ]);
useEffect(() =>
{
if(!loginImageUrls.length) return;
let cancelled = false;
loginImageUrls.forEach(url =>
{
const image = new Image();
image.onload = image.onerror = () =>
{
if(!cancelled) setLoginImagesVersion(version => version + 1);
};
image.src = url;
});
return () =>
{
cancelled = true;
};
}, [ loginImageUrls ]);
useEffect(() =>
{
if(!info) return;
@@ -138,7 +173,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
return { ok: response.ok, status: response.status, payload };
}, []);
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '/api/health');
const healthUrl = GetConfigurationValue<string>('login.health.endpoint', '');
const healthMethodRaw = GetConfigurationValue<string>('login.health.method', 'GET');
const healthMethod = (healthMethodRaw || 'GET').toUpperCase();
const checkServerReachable = useCallback(async (): Promise<boolean> =>
@@ -196,7 +231,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
{
event.preventDefault();
if(submitting) return;
if(submitting || isEntering) return;
const nowTs = Date.now();
if(nowTs - submitTimeRef.current < 1000) return;
@@ -263,7 +298,7 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
{
setSubmitting(false);
}
}, [ submitting, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
}, [ submitting, isEntering, username, password, turnstileEnabled, loginTurnstileToken, loginUrl, postJson, clearLock, recordFailure, onAuthenticated, resetLoginTurnstile, pingLoginServer ]);
const checkEmailUrl = GetConfigurationValue<string>('login.check-email.endpoint', '/api/auth/check-email');
const checkUsernameUrl = GetConfigurationValue<string>('login.check-username.endpoint', '/api/auth/check-username');
@@ -409,12 +444,15 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
className="nitro-login-view"
style={ backgroundColor ? { background: backgroundColor } : undefined }
>
{ background ? <div className="login-background login-layer" style={ { backgroundImage: `url(${ background })` } } /> : null }
{ sun ? <div className="login-sun login-layer" style={ { backgroundImage: `url(${ sun })` } } /> : null }
{ drape ? <div className="login-drape login-layer" style={ { backgroundImage: `url(${ drape })` } } /> : null }
{ left ? <div className="login-left login-layer" style={ { backgroundImage: `url(${ left })` } } /> : null }
{ background ? <img className="login-background login-layer login-layer-img" src={ background } alt="" draggable={ false } /> : null }
{ sun ? <img className="login-sun login-layer login-layer-img" src={ sun } alt="" draggable={ false } /> : null }
{ drape ? <img className="login-drape login-layer login-layer-img" src={ drape } alt="" draggable={ false } /> : null }
{ left ? <img className="login-left login-layer login-layer-img" src={ left } alt="" draggable={ false } /> : null }
{ rightRepeat ? <div className="login-right-repeat login-layer" style={ { backgroundImage: `url(${ rightRepeat })` } } /> : null }
{ right ? <div className="login-right login-layer" style={ { backgroundImage: `url(${ right })` } } /> : null }
{ right ? <img className="login-right login-layer login-layer-img" src={ right } alt="" draggable={ false } /> : null }
<div className="login-image-preloader" aria-hidden="true" data-version={ loginImagesVersion }>
{ loginImageUrls.map(url => <img key={ url } src={ url } decoding="async" loading="eager" alt="" />) }
</div>
<div className="login-stack">
<div className="nitro-login-card">
@@ -475,8 +513,8 @@ export const LoginView: FC<LoginViewProps> = ({ onAuthenticated }) =>
<button
type="submit"
className="ok-button"
disabled={ submitting || isLocked || loginServerReachable === false || loginPingingServer }
>{ loginPingingServer ? 'Checking' : 'OK' }</button>
disabled={ submitting || isEntering || isLocked || loginServerReachable === false || loginPingingServer }
>{ isEntering ? 'Entrando' : loginPingingServer ? 'Checking' : 'OK' }</button>
</div>
<a className="forgot" onClick={ () => setMode('forgot') }>Forgotten your password?</a>
</form>
+32 -8
View File
@@ -12,6 +12,28 @@
position: absolute;
background-repeat: no-repeat;
pointer-events: none;
transform: translateZ(0);
}
.nitro-login-view .login-layer-img {
display: block;
user-select: none;
-webkit-user-drag: none;
object-fit: none;
}
.nitro-login-view .login-image-preloader {
position: absolute;
width: 1px;
height: 1px;
overflow: hidden;
opacity: 0;
pointer-events: none;
}
.nitro-login-view .login-image-preloader img {
width: 1px;
height: 1px;
}
.nitro-login-view .login-background {
@@ -19,8 +41,8 @@
left: 0;
width: 100%;
height: 100%;
background-repeat: repeat-x;
background-position: center top;
object-fit: cover;
object-position: center top;
}
.nitro-login-view .login-sun {
@@ -28,8 +50,8 @@
transform: translateX(-50%);
width: 600px;
height: 600px;
background-size: contain;
background-position: center top;
object-fit: contain;
object-position: center top;
}
.nitro-login-view .login-drape {
@@ -38,6 +60,8 @@
width: 190px;
height: 220px;
z-index: 3;
object-fit: contain;
object-position: left top;
}
.nitro-login-view .login-left {
@@ -45,9 +69,8 @@
left: 0;
width: 100%;
height: 100%;
background-position: left bottom;
background-size: auto;
background-repeat: no-repeat;
object-fit: none;
object-position: left bottom;
}
.nitro-login-view .login-right-repeat {
@@ -64,7 +87,8 @@
right: 0;
width: 400px;
height: 100%;
background-position: right bottom;
object-fit: none;
object-position: right bottom;
}
/* ─── Foreground Login Card Stack ─── */
+378
View File
@@ -0,0 +1,378 @@
type SecureSession = {
publicKey: string;
key: CryptoKey;
fingerprint: string;
};
const isDebugEnabled = (): boolean =>
{
try
{
const search = new URLSearchParams(window.location.search);
return search.get('secureDebug') === '1' || localStorage.getItem('nitro.secure.debug') === '1';
}
catch
{
return false;
}
};
const setDebugState = (message: string): void =>
{
try
{
(window as any).__nitroSecureDebug = message;
const log = Array.isArray((window as any).__nitroSecureDebugLog)
? (window as any).__nitroSecureDebugLog
: [];
log.push(message);
(window as any).__nitroSecureDebugLog = log.slice(-50);
if(!isDebugEnabled()) return;
const existing = document.getElementById('nitro-secure-debug');
if(existing)
{
existing.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n');
return;
}
const node = document.createElement('div');
node.id = 'nitro-secure-debug';
node.style.position = 'fixed';
node.style.left = '8px';
node.style.bottom = '8px';
node.style.zIndex = '2147483647';
node.style.padding = '6px 8px';
node.style.maxWidth = '70vw';
node.style.background = 'rgba(0,0,0,0.85)';
node.style.color = '#00ff90';
node.style.font = '12px monospace';
node.style.whiteSpace = 'pre-wrap';
node.style.pointerEvents = 'none';
node.textContent = (window as any).__nitroSecureDebugLog.slice(-8).join('\n');
document.body.appendChild(node);
}
catch {}
};
const textEncoder = new TextEncoder();
const textDecoder = new TextDecoder();
let secureSessionPromise: Promise<SecureSession> = null;
let installed = false;
const secureResponseCache = new Map<string, Promise<Response>>();
const bytesToBase64 = (bytes: ArrayBuffer): string =>
{
let binary = '';
const view = new Uint8Array(bytes);
for(let index = 0; index < view.length; index++) binary += String.fromCharCode(view[index]);
return btoa(binary);
};
const hexValue = (code: number): number =>
{
if(code >= 48 && code <= 57) return code - 48;
if(code >= 65 && code <= 70) return code - 55;
if(code >= 97 && code <= 102) return code - 87;
return -1;
};
const hexToBytes = (hex: string): Uint8Array =>
{
const normalized = hex.trim();
if((normalized.length % 2) !== 0) throw new Error('Invalid encrypted hex payload.');
const bytes = new Uint8Array(normalized.length / 2);
for(let index = 0; index < bytes.length; index++)
{
const high = hexValue(normalized.charCodeAt(index * 2));
const low = hexValue(normalized.charCodeAt((index * 2) + 1));
if(high < 0 || low < 0) throw new Error('Invalid encrypted hex payload.');
bytes[index] = (high << 4) | low;
}
return bytes;
};
const deriveAesKey = async (privateKey: CryptoKey, serverKeyBase64: string): Promise<{ key: CryptoKey; fingerprint: string }> =>
{
const serverBytes = Uint8Array.from(atob(serverKeyBase64), char => char.charCodeAt(0));
const serverKey = await crypto.subtle.importKey(
'spki',
serverBytes,
{ name: 'ECDH', namedCurve: 'P-256' },
false,
[]
);
const secret = await crypto.subtle.deriveBits({ name: 'ECDH', public: serverKey }, privateKey, 256);
const salt = textEncoder.encode('nitro-secure-assets-v1');
const material = new Uint8Array(secret.byteLength + salt.length);
material.set(new Uint8Array(secret), 0);
material.set(salt, secret.byteLength);
const hash = await crypto.subtle.digest('SHA-256', material);
const fingerprintHash = await crypto.subtle.digest('SHA-256', hash);
const fingerprint = Array.from(new Uint8Array(fingerprintHash).slice(0, 8)).map(value => value.toString(16).padStart(2, '0')).join('');
return {
key: await crypto.subtle.importKey('raw', hash, 'AES-GCM', false, [ 'encrypt', 'decrypt' ]),
fingerprint
};
};
const getApiBase = (): string =>
{
const configured = (window as any).NitroSecureApiUrl;
if(typeof configured === 'string' && configured.length) return configured.replace(/\/$/, '');
return 'https://nitro.slogga.it:2096';
};
export const secureUrl = (kind: 'config' | 'gamedata', file: string): string =>
{
const base = getApiBase();
return `${ base }/nitro-sec/file?kind=${ encodeURIComponent(kind) }&file=${ encodeURIComponent(file) }`;
};
const createSecureSession = async (): Promise<SecureSession> =>
{
setDebugState('secure: generating ECDH session');
const pair = await crypto.subtle.generateKey(
{ name: 'ECDH', namedCurve: 'P-256' },
true,
[ 'deriveBits' ]
);
const publicKey = await crypto.subtle.exportKey('spki', pair.publicKey);
const response = await fetch(`${ getApiBase() }/nitro-sec/bootstrap`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key: bytesToBase64(publicKey) })
});
if(!response.ok) throw new Error(`Secure bootstrap failed: HTTP ${ response.status }`);
const fingerprint = response.headers.get('X-Nitro-Key-Fp') || 'none';
const payload = await response.json();
const serverKey = typeof payload.key === 'string' ? payload.key : '';
const clientPublicKey = bytesToBase64(publicKey);
if(!serverKey) throw new Error('Secure bootstrap returned an invalid server key.');
setDebugState(`secure: bootstrap ok fp=${ fingerprint }`);
const derived = await deriveAesKey(pair.privateKey, serverKey);
return { publicKey: clientPublicKey, key: derived.key, fingerprint: derived.fingerprint };
};
export const getSecureSession = (): Promise<SecureSession> =>
{
if(!secureSessionPromise) secureSessionPromise = createSecureSession();
return secureSessionPromise;
};
const decryptResponse = async (session: SecureSession, response: Response): Promise<Response> =>
{
setDebugState(`secure: decrypt start status=${ response.status }`);
const bytes = hexToBytes(await response.text());
if(bytes.length < 13) throw new Error('Encrypted response is too short.');
const iv = bytes.slice(0, 12);
const payload = bytes.slice(12);
const clear = await crypto.subtle.decrypt({ name: 'AES-GCM', iv }, session.key, payload);
const headers = new Headers(response.headers);
headers.set('Content-Type', 'application/json; charset=utf-8');
headers.delete('X-Nitro-Sec');
const text = textDecoder.decode(clear);
setDebugState(`secure: decrypt ok bytes=${ bytes.length }`);
return new Response(text, {
status: response.status,
statusText: response.statusText,
headers
});
};
const cloneCachedResponse = async (responsePromise: Promise<Response>): Promise<Response> =>
{
const response = await responsePromise;
return response.clone();
};
const normalizeSecureCacheKey = (requestUrl: string): string =>
{
try
{
const url = new URL(requestUrl, window.location.href);
if(!url.pathname.includes('/nitro-sec/file')) return requestUrl;
const kind = url.searchParams.get('kind') || '';
const file = (url.searchParams.get('file') || '')
.replace(/^[\\/]+/, '')
.split('?')[0]
.split('#')[0];
return `${ url.origin }${ url.pathname }?kind=${ kind }&file=${ file }`;
}
catch
{
return requestUrl;
}
};
const bytesToHex = (bytes: Uint8Array): string =>
{
let output = '';
for(let index = 0; index < bytes.length; index++) output += bytes[index].toString(16).padStart(2, '0');
return output;
};
const encryptBytes = async (session: SecureSession, clear: ArrayBuffer): Promise<string> =>
{
const iv = crypto.getRandomValues(new Uint8Array(12));
const encrypted = new Uint8Array(await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, session.key, clear));
const out = new Uint8Array(iv.length + encrypted.length);
out.set(iv, 0);
out.set(encrypted, iv.length);
return bytesToHex(out);
};
const isApiUrl = (requestUrl: string): boolean =>
{
try
{
return new URL(requestUrl, window.location.href).pathname.startsWith('/api/');
}
catch
{
return requestUrl.startsWith('/api/');
}
};
const readRequestBody = async (input: RequestInfo | URL, init: RequestInit | undefined, method: string): Promise<ArrayBuffer | null> =>
{
if(method === 'GET' || method === 'HEAD') return null;
if(init?.body !== undefined)
{
if(typeof init.body === 'string') return textEncoder.encode(init.body).buffer;
if(init.body instanceof ArrayBuffer) return init.body;
if(ArrayBuffer.isView(init.body)) return init.body.buffer.slice(init.body.byteOffset, init.body.byteOffset + init.body.byteLength);
if(init.body instanceof Blob) return init.body.arrayBuffer();
}
if(input instanceof Request) return input.clone().arrayBuffer();
return null;
};
export const installSecureFetch = (): void =>
{
if(installed) return;
installed = true;
const nativeFetch = window.fetch.bind(window);
window.fetch = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> =>
{
const requestUrl = typeof input === 'string'
? input
: input instanceof URL
? input.toString()
: input.url;
if(requestUrl.includes('/nitro-sec/file'))
{
const method = init?.method || (input instanceof Request ? input.method : 'GET');
const cacheKey = method.toUpperCase() === 'GET' ? normalizeSecureCacheKey(requestUrl) : null;
if(cacheKey && secureResponseCache.has(cacheKey)) return cloneCachedResponse(secureResponseCache.get(cacheKey));
const responsePromise = (async () =>
{
const session = await getSecureSession();
setDebugState(`secure: fetching ${ requestUrl }`);
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
headers.set('X-Nitro-Key', session.publicKey);
const response = await nativeFetch(input, { ...init, headers });
setDebugState(`secure: response ${ response.status } encrypted=${ response.headers.get('X-Nitro-Sec') === '1' } fp=${ response.headers.get('X-Nitro-Key-Fp') || 'none' } derive=${ response.headers.get('X-Nitro-Derive-Fp') || 'none' } client=${ session.fingerprint }`);
if(response.headers.get('X-Nitro-Sec') === '1')
{
try
{
const decrypted = await decryptResponse(session, response);
setDebugState(`secure: decrypted ${ requestUrl }`);
return decrypted;
}
catch(error)
{
setDebugState(`secure: decrypt failed ${ (error as Error)?.message || error }`);
throw error;
}
}
setDebugState(`secure: plain response ${ requestUrl } status=${ response.status }`);
return response;
})();
if(cacheKey) secureResponseCache.set(cacheKey, responsePromise);
return cloneCachedResponse(responsePromise);
}
if(isApiUrl(requestUrl))
{
const method = (init?.method || (input instanceof Request ? input.method : 'GET')).toUpperCase();
const session = await getSecureSession();
const headers = new Headers(init?.headers || (input instanceof Request ? input.headers : undefined));
const clearBody = await readRequestBody(input, init, method);
const encryptedInit: RequestInit = { ...init, method, headers };
headers.set('X-Nitro-Key', session.publicKey);
headers.set('X-Nitro-Api', '1');
if(clearBody)
{
encryptedInit.body = await encryptBytes(session, clearBody);
headers.set('Content-Type', 'text/plain; charset=utf-8');
}
const response = await nativeFetch(input, encryptedInit);
if(response.headers.get('X-Nitro-Sec') === '1') return decryptResponse(session, response);
return response;
}
return nativeFetch(input, init);
};
};