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 + ); }