From 7957a8f7f3bd822e156c935e2aeca8ca972a1099 Mon Sep 17 00:00:00 2001 From: duckietm Date: Thu, 23 Apr 2026 15:57:24 +0200 Subject: [PATCH 1/3] =?UTF-8?q?=F0=9F=86=95=20Handshake=20on=20connect?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../communication/src/SocketConnection.ts | 151 +++++++++++++++++- .../src/crypto/WsSessionCrypto.ts | 80 ++++++++++ packages/communication/src/crypto/index.ts | 1 + packages/communication/src/index.ts | 1 + 4 files changed, 226 insertions(+), 7 deletions(-) create mode 100644 packages/communication/src/crypto/WsSessionCrypto.ts create mode 100644 packages/communication/src/crypto/index.ts diff --git a/packages/communication/src/SocketConnection.ts b/packages/communication/src/SocketConnection.ts index fc4dac5..77df024 100644 --- a/packages/communication/src/SocketConnection.ts +++ b/packages/communication/src/SocketConnection.ts @@ -1,9 +1,25 @@ import { ICodec, IConnection, IMessageComposer, IMessageConfiguration, IMessageDataWrapper, IMessageEvent, WebSocketEventEnum } from '@nitrots/api'; +import { GetConfiguration } from '@nitrots/configuration'; import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events'; import { NitroLogger } from '@nitrots/utils'; import { EvaWireFormat } from './codec'; +import { + aesGcmDecrypt, + aesGcmEncrypt, + buildClientHello, + deriveAesKey, + deriveSharedSecret, + exportPublicKeySpki, + generateEphemeralKeyPair, + importPublicKeySpki, + NONCE_LEN, + parseServerHello, + randomNonce +} from './crypto'; import { MessageClassManager } from './messages'; +type CryptoState = 'disabled' | 'awaiting_server_hello' | 'ready' | 'error'; + export class SocketConnection implements IConnection { private _socket: WebSocket = null; @@ -29,6 +45,10 @@ export class SocketConnection implements IConnection public static readonly BASE_RECONNECT_DELAY_MS: number = 1000; public static readonly MAX_RECONNECT_DELAY_MS: number = 30000; + private _cryptoState: CryptoState = 'disabled'; + private _sessionKey: CryptoKey = null; + private _pendingEncryptedSends: ArrayBuffer[] = []; + public init(socketUrl: string): void { if(!socketUrl || !socketUrl.length) return; @@ -43,16 +63,27 @@ export class SocketConnection implements IConnection { this._dataBuffer = new ArrayBuffer(0); + const cryptoEnabled = !!GetConfiguration().getValue('crypto.ws.enabled', false); + if(cryptoEnabled && !this.subtleCryptoAvailable()) + { + NitroLogger.error('[ws-crypto] crypto.ws.enabled=true but window.crypto.subtle is unavailable. ' + + 'This page must be served from a secure context - HTTPS, localhost, or 127.0.0.1. ' + + 'Current origin: ' + (typeof window !== 'undefined' ? window.location.origin : 'unknown')); + this._cryptoState = 'error'; + } + else + { + this._cryptoState = cryptoEnabled ? 'awaiting_server_hello' : 'disabled'; + } + this._sessionKey = null; + this._pendingEncryptedSends = []; + this._socket = new WebSocket(socketUrl); this._socket.binaryType = 'arraybuffer'; this._onOpenCallback = () => this.onSocketOpened(); this._onCloseCallback = (event: Event) => this.onSocketClosed(event as CloseEvent); this._onErrorCallback = () => this.onSocketError(); - this._onMessageCallback = (event: MessageEvent) => - { - this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, event.data); - this.processReceivedData(); - }; + this._onMessageCallback = (event: MessageEvent) => this.onSocketMessage(event.data as ArrayBuffer); this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback); this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback); @@ -60,6 +91,97 @@ export class SocketConnection implements IConnection this._socket.addEventListener(WebSocketEventEnum.CONNECTION_MESSAGE, this._onMessageCallback); } + private subtleCryptoAvailable(): boolean + { + return typeof window !== 'undefined' + && typeof window.crypto !== 'undefined' + && typeof window.crypto.subtle !== 'undefined'; + } + + private onSocketMessage(data: ArrayBuffer): void + { + if(this._cryptoState === 'error') + { + this._intentionalClose = true; + if(this._socket) this._socket.close(); + return; + } + + if(this._cryptoState === 'awaiting_server_hello') + { + this.handleServerHello(data) + .catch(err => + { + NitroLogger.error('[ws-crypto] handshake failed', err); + this._cryptoState = 'error'; + this._intentionalClose = true; + if(this._socket) this._socket.close(); + }); + return; + } + + if(this._cryptoState === 'ready') + { + this.decryptFrame(data) + .then(plain => + { + this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, plain); + this.processReceivedData(); + }) + .catch(err => + { + NitroLogger.error('[ws-crypto] decrypt failed', err); + this._cryptoState = 'error'; + this._intentionalClose = true; + if(this._socket) this._socket.close(); + }); + return; + } + + if(this._cryptoState === 'error') return; + + this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, data); + this.processReceivedData(); + } + + private async handleServerHello(frame: ArrayBuffer): Promise + { + const serverPubkeySpki = parseServerHello(frame); + const serverPubkey = await importPublicKeySpki(serverPubkeySpki); + const ourKeys = await generateEphemeralKeyPair(); + const ourPubkeySpki = await exportPublicKeySpki(ourKeys.publicKey); + const shared = await deriveSharedSecret(ourKeys.privateKey, serverPubkey); + this._sessionKey = await deriveAesKey(shared); + this._socket.send(buildClientHello(ourPubkeySpki)); + this._cryptoState = 'ready'; + + if(this._pendingEncryptedSends.length) + { + const queued = this._pendingEncryptedSends; + this._pendingEncryptedSends = []; + for(const buf of queued) await this.encryptAndSend(buf); + } + } + + private async decryptFrame(frame: ArrayBuffer): Promise + { + if(frame.byteLength < NONCE_LEN + 16) throw new Error('encrypted frame too short'); + const nonce = new Uint8Array(frame, 0, NONCE_LEN); + const ct = frame.slice(NONCE_LEN); + return aesGcmDecrypt(this._sessionKey, nonce, ct); + } + + private async encryptAndSend(plaintext: ArrayBuffer): Promise + { + if(!this._sessionKey) return; + const nonce = randomNonce(); + const ct = await aesGcmEncrypt(this._sessionKey, nonce, plaintext); + const framed = new Uint8Array(NONCE_LEN + ct.byteLength); + framed.set(nonce, 0); + framed.set(new Uint8Array(ct), NONCE_LEN); + if(this._socket && this._socket.readyState === WebSocket.OPEN) this._socket.send(framed.buffer); + } + private onSocketOpened(): void { if(this._isReconnecting) @@ -270,7 +392,23 @@ export class SocketConnection implements IConnection { if(!this._socket || this._socket.readyState !== WebSocket.OPEN) return; - this._socket.send(buffer); + if(this._cryptoState === 'disabled') + { + this._socket.send(buffer); + return; + } + + if(this._cryptoState === 'ready') + { + this.encryptAndSend(buffer).catch(err => NitroLogger.error('[ws-crypto] encrypt failed', err)); + return; + } + + if(this._cryptoState === 'awaiting_server_hello') + { + this._pendingEncryptedSends.push(buffer); + return; + } } public processReceivedData(): void @@ -354,7 +492,6 @@ export class SocketConnection implements IConnection try { - //@ts-ignore const parser = new events[0].parserClass(); if(!parser || !parser.flush() || !parser.parse(wrapper)) return null; diff --git a/packages/communication/src/crypto/WsSessionCrypto.ts b/packages/communication/src/crypto/WsSessionCrypto.ts new file mode 100644 index 0000000..ce6ad62 --- /dev/null +++ b/packages/communication/src/crypto/WsSessionCrypto.ts @@ -0,0 +1,80 @@ +export const HANDSHAKE_MAGIC = 0xC0DEC0DE | 0; +export const TYPE_SERVER_HELLO = 0x01; +export const TYPE_CLIENT_HELLO = 0x02; +export const HKDF_INFO = 'nitro-ws-v1'; +export const AES_KEY_BITS = 256; +export const NONCE_LEN = 12; +export const GCM_TAG_LEN = 16; + +export async function generateEphemeralKeyPair(): Promise +{ + return window.crypto.subtle.generateKey({ name: 'ECDH', namedCurve: 'P-256' }, true, [ 'deriveBits' ]); +} + +export async function exportPublicKeySpki(publicKey: CryptoKey): Promise +{ + return window.crypto.subtle.exportKey('spki', publicKey); +} + +export async function importPublicKeySpki(spki: ArrayBuffer): Promise +{ + return window.crypto.subtle.importKey('spki', spki, { name: 'ECDH', namedCurve: 'P-256' }, false, []); +} + +export async function deriveSharedSecret(ourPrivate: CryptoKey, theirPublic: CryptoKey): Promise +{ + return window.crypto.subtle.deriveBits({ name: 'ECDH', public: theirPublic }, ourPrivate, 256); +} + +export async function deriveAesKey(sharedSecret: ArrayBuffer): Promise +{ + const ikm = await window.crypto.subtle.importKey('raw', sharedSecret, 'HKDF', false, [ 'deriveKey' ]); + return window.crypto.subtle.deriveKey( + { name: 'HKDF', hash: 'SHA-256', salt: new Uint8Array(32), info: new TextEncoder().encode(HKDF_INFO) }, + ikm, + { name: 'AES-GCM', length: AES_KEY_BITS }, + false, + [ 'encrypt', 'decrypt' ] + ); +} + +export async function aesGcmEncrypt(key: CryptoKey, nonce: Uint8Array, plaintext: ArrayBuffer): Promise +{ + return window.crypto.subtle.encrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, plaintext); +} + +export async function aesGcmDecrypt(key: CryptoKey, nonce: Uint8Array, ciphertextWithTag: ArrayBuffer): Promise +{ + return window.crypto.subtle.decrypt({ name: 'AES-GCM', iv: nonce, tagLength: GCM_TAG_LEN * 8 }, key, ciphertextWithTag); +} + +export function randomNonce(): Uint8Array +{ + const n = new Uint8Array(NONCE_LEN); + window.crypto.getRandomValues(n); + return n; +} + +export function buildClientHello(pubkeySpki: ArrayBuffer): ArrayBuffer +{ + const out = new Uint8Array(4 + 1 + 2 + pubkeySpki.byteLength); + const dv = new DataView(out.buffer); + dv.setUint32(0, HANDSHAKE_MAGIC, false); + out[4] = TYPE_CLIENT_HELLO; + dv.setUint16(5, pubkeySpki.byteLength, false); + out.set(new Uint8Array(pubkeySpki), 7); + return out.buffer; +} + +export function parseServerHello(frame: ArrayBuffer): ArrayBuffer +{ + if (frame.byteLength < 7) throw new Error('server_hello frame too short'); + const dv = new DataView(frame); + const magic = dv.getUint32(0, false); + if (magic >>> 0 !== (HANDSHAKE_MAGIC >>> 0)) throw new Error('server_hello magic mismatch'); + const type = dv.getUint8(4); + if (type !== TYPE_SERVER_HELLO) throw new Error(`expected server_hello, got type=0x${ type.toString(16) }`); + const keyLen = dv.getUint16(5, false); + if (keyLen <= 0 || keyLen > frame.byteLength - 7) throw new Error(`invalid server key length ${ keyLen }`); + return frame.slice(7, 7 + keyLen); +} diff --git a/packages/communication/src/crypto/index.ts b/packages/communication/src/crypto/index.ts new file mode 100644 index 0000000..0a97306 --- /dev/null +++ b/packages/communication/src/crypto/index.ts @@ -0,0 +1 @@ +export * from './WsSessionCrypto'; diff --git a/packages/communication/src/index.ts b/packages/communication/src/index.ts index 300c2c9..da3786f 100644 --- a/packages/communication/src/index.ts +++ b/packages/communication/src/index.ts @@ -4,6 +4,7 @@ export * from './NitroMessages'; export * from './SocketConnection'; export * from './codec'; export * from './codec/evawire'; +export * from './crypto'; export * from './messages'; export * from './messages/incoming'; export * from './messages/incoming/advertisement'; From e1cc87afa3aa74b2c6ba4f923345f7ddfd5ae04b Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 24 Apr 2026 13:55:18 +0200 Subject: [PATCH 2/3] =?UTF-8?q?=F0=9F=86=99=20Fix=20background=20clipping?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/src/renderer/RoomSpriteCanvas.ts | 41 +++---------------- 1 file changed, 6 insertions(+), 35 deletions(-) diff --git a/packages/room/src/renderer/RoomSpriteCanvas.ts b/packages/room/src/renderer/RoomSpriteCanvas.ts index 19a4b67..e1f1b09 100644 --- a/packages/room/src/renderer/RoomSpriteCanvas.ts +++ b/packages/room/src/renderer/RoomSpriteCanvas.ts @@ -378,49 +378,20 @@ export class RoomSpriteCanvas implements IRoomRenderingCanvas let leftIdx = 0; let rightIdx = 0; + let maxY = hull[0].y; for(let i = 1; i < hull.length; i++) { if(hull[i].x < hull[leftIdx].x) leftIdx = i; if(hull[i].x > hull[rightIdx].x) rightIdx = i; + if(hull[i].y > maxY) maxY = hull[i].y; } - const n = hull.length; - - // Collect arc going CCW: leftIdx → rightIdx via increasing indices - const arcCCW: { x: number; y: number }[] = []; - let idx = leftIdx; - - while(idx !== rightIdx) - { - arcCCW.push(hull[idx]); - idx = (idx + 1) % n; - } - - arcCCW.push(hull[rightIdx]); - - // Collect arc going CW: leftIdx → rightIdx via decreasing indices - const arcCW: { x: number; y: number }[] = []; - idx = leftIdx; - - while(idx !== rightIdx) - { - arcCW.push(hull[idx]); - idx = (idx - 1 + n) % n; - } - - arcCW.push(hull[rightIdx]); - - // Bottom arc = the arc with larger average Y (floor/front tiles) - const avgCCW = arcCCW.reduce((s, p) => s + p.y, 0) / arcCCW.length; - const avgCW = arcCW.reduce((s, p) => s + p.y, 0) / arcCW.length; - const bottomArc = avgCCW >= avgCW ? arcCCW : arcCW; - - // Build polygon: extend upward far above walls, then trace bottom boundary return [ - { x: hull[leftIdx].x, y: -extension }, - { x: hull[rightIdx].x, y: -extension }, - ...bottomArc.slice().reverse() + { x: hull[leftIdx].x - extension, y: -extension }, + { x: hull[rightIdx].x + extension, y: -extension }, + { x: hull[rightIdx].x + extension, y: maxY + extension }, + { x: hull[leftIdx].x - extension, y: maxY + extension } ]; } From 455b75e41d8741bf89d6c16ff4218e705e14d52a Mon Sep 17 00:00:00 2001 From: duckietm Date: Fri, 24 Apr 2026 16:24:02 +0200 Subject: [PATCH 3/3] =?UTF-8?q?=F0=9F=86=99=20CryptoV2?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../communication/src/SocketConnection.ts | 55 ++++++++++++------- .../src/crypto/WsSessionCrypto.ts | 43 ++++++++++++++- 2 files changed, 77 insertions(+), 21 deletions(-) diff --git a/packages/communication/src/SocketConnection.ts b/packages/communication/src/SocketConnection.ts index 77df024..f0027fe 100644 --- a/packages/communication/src/SocketConnection.ts +++ b/packages/communication/src/SocketConnection.ts @@ -3,19 +3,7 @@ import { GetConfiguration } from '@nitrots/configuration'; import { GetEventDispatcher, NitroEvent, NitroEventType, ReconnectEvent } from '@nitrots/events'; import { NitroLogger } from '@nitrots/utils'; import { EvaWireFormat } from './codec'; -import { - aesGcmDecrypt, - aesGcmEncrypt, - buildClientHello, - deriveAesKey, - deriveSharedSecret, - exportPublicKeySpki, - generateEphemeralKeyPair, - importPublicKeySpki, - NONCE_LEN, - parseServerHello, - randomNonce -} from './crypto'; +import { aesGcmDecrypt, aesGcmEncrypt, buildClientHello, deriveAesKey, deriveSharedSecret, exportPublicKeySpki, generateEphemeralKeyPair, importPublicKeySpki, importSigningPublicKeyFromBase64, NONCE_LEN, parseServerHello, randomNonce, verifyEphemeralSignature } from './crypto'; import { MessageClassManager } from './messages'; type CryptoState = 'disabled' | 'awaiting_server_hello' | 'ready' | 'error'; @@ -40,7 +28,7 @@ export class SocketConnection implements IConnection private _isReconnecting: boolean = false; private _intentionalClose: boolean = false; private _wasAuthenticated: boolean = false; - + public static readonly MAX_RECONNECT_ATTEMPTS: number = 7; public static readonly BASE_RECONNECT_DELAY_MS: number = 1000; public static readonly MAX_RECONNECT_DELAY_MS: number = 30000; @@ -62,7 +50,6 @@ export class SocketConnection implements IConnection private createSocket(socketUrl: string): void { this._dataBuffer = new ArrayBuffer(0); - const cryptoEnabled = !!GetConfiguration().getValue('crypto.ws.enabled', false); if(cryptoEnabled && !this.subtleCryptoAvailable()) { @@ -84,7 +71,6 @@ export class SocketConnection implements IConnection this._onCloseCallback = (event: Event) => this.onSocketClosed(event as CloseEvent); this._onErrorCallback = () => this.onSocketError(); this._onMessageCallback = (event: MessageEvent) => this.onSocketMessage(event.data as ArrayBuffer); - this._socket.addEventListener(WebSocketEventEnum.CONNECTION_OPENED, this._onOpenCallback); this._socket.addEventListener(WebSocketEventEnum.CONNECTION_CLOSED, this._onCloseCallback); this._socket.addEventListener(WebSocketEventEnum.CONNECTION_ERROR, this._onErrorCallback); @@ -138,15 +124,23 @@ export class SocketConnection implements IConnection return; } - if(this._cryptoState === 'error') return; - this._dataBuffer = this.concatArrayBuffers(this._dataBuffer, data); this.processReceivedData(); } private async handleServerHello(frame: ArrayBuffer): Promise { - const serverPubkeySpki = parseServerHello(frame); + const { pubkeySpki: serverPubkeySpki, signature } = parseServerHello(frame); + const signingRequired = !!GetConfiguration().getValue('crypto.ws.signing.enabled', false); + if(signingRequired) + { + if(!signature) throw new Error('crypto.ws.signing.enabled=true but server_hello had no signature'); + + const signingPub = await this.getSigningPublicKey(); + const ok = await verifyEphemeralSignature(signingPub, signature, serverPubkeySpki); + if(!ok) throw new Error('server_hello signature verification failed (MITM?)'); + } + const serverPubkey = await importPublicKeySpki(serverPubkeySpki); const ourKeys = await generateEphemeralKeyPair(); const ourPubkeySpki = await exportPublicKeySpki(ourKeys.publicKey); @@ -163,6 +157,29 @@ export class SocketConnection implements IConnection } } + private _cachedSigningPublicKey: CryptoKey = null; + private async getSigningPublicKey(): Promise + { + if(this._cachedSigningPublicKey) return this._cachedSigningPublicKey; + + const pinned = GetConfiguration().getValue('crypto.ws.signing.public_key', ''); + if(pinned) + { + this._cachedSigningPublicKey = await importSigningPublicKeyFromBase64(pinned); + return this._cachedSigningPublicKey; + } + + const endpointTemplate = GetConfiguration().getValue('login.server_key.endpoint', '/api/auth/server-key'); + const endpoint = GetConfiguration().interpolate(endpointTemplate); + const resp = await fetch(endpoint, { credentials: 'include' }); + if(!resp.ok) throw new Error(`server-key fetch failed: HTTP ${ resp.status }`); + const payload = await resp.json(); + const b64 = typeof payload?.publicKey === 'string' ? payload.publicKey : ''; + if(!b64) throw new Error('server-key response missing publicKey'); + this._cachedSigningPublicKey = await importSigningPublicKeyFromBase64(b64); + return this._cachedSigningPublicKey; + } + private async decryptFrame(frame: ArrayBuffer): Promise { if(frame.byteLength < NONCE_LEN + 16) throw new Error('encrypted frame too short'); diff --git a/packages/communication/src/crypto/WsSessionCrypto.ts b/packages/communication/src/crypto/WsSessionCrypto.ts index ce6ad62..e92078c 100644 --- a/packages/communication/src/crypto/WsSessionCrypto.ts +++ b/packages/communication/src/crypto/WsSessionCrypto.ts @@ -66,7 +66,13 @@ export function buildClientHello(pubkeySpki: ArrayBuffer): ArrayBuffer return out.buffer; } -export function parseServerHello(frame: ArrayBuffer): ArrayBuffer +export interface ParsedServerHello +{ + pubkeySpki: ArrayBuffer; + signature: ArrayBuffer | null; +} + +export function parseServerHello(frame: ArrayBuffer): ParsedServerHello { if (frame.byteLength < 7) throw new Error('server_hello frame too short'); const dv = new DataView(frame); @@ -74,7 +80,40 @@ export function parseServerHello(frame: ArrayBuffer): ArrayBuffer if (magic >>> 0 !== (HANDSHAKE_MAGIC >>> 0)) throw new Error('server_hello magic mismatch'); const type = dv.getUint8(4); if (type !== TYPE_SERVER_HELLO) throw new Error(`expected server_hello, got type=0x${ type.toString(16) }`); + const keyLen = dv.getUint16(5, false); if (keyLen <= 0 || keyLen > frame.byteLength - 7) throw new Error(`invalid server key length ${ keyLen }`); - return frame.slice(7, 7 + keyLen); + const pubkeySpki = frame.slice(7, 7 + keyLen); + + const remaining = frame.byteLength - (7 + keyLen); + if (remaining === 0) return { pubkeySpki, signature: null }; + if (remaining < 2) throw new Error('truncated signature trailer'); + const sigLen = dv.getUint16(7 + keyLen, false); + if (sigLen <= 0 || 7 + keyLen + 2 + sigLen !== frame.byteLength) throw new Error(`invalid signature length ${ sigLen }`); + const signature = frame.slice(7 + keyLen + 2, 7 + keyLen + 2 + sigLen); + return { pubkeySpki, signature }; +} + +export async function importSigningPublicKeyFromBase64(spkiBase64: string): Promise +{ + const bin = atob(spkiBase64.replace(/-/g, '+').replace(/_/g, '/')); + const bytes = new Uint8Array(bin.length); + for (let i = 0; i < bin.length; i++) bytes[i] = bin.charCodeAt(i); + return window.crypto.subtle.importKey( + 'spki', + bytes.buffer, + { name: 'ECDSA', namedCurve: 'P-256' }, + false, + [ 'verify' ] + ); +} + +export async function verifyEphemeralSignature(signingKey: CryptoKey, signature: ArrayBuffer, signedBytes: ArrayBuffer): Promise +{ + return window.crypto.subtle.verify( + { name: 'ECDSA', hash: 'SHA-256' }, + signingKey, + signature, + signedBytes + ); }