🆙 CryptoV2

This commit is contained in:
duckietm
2026-04-24 16:24:02 +02:00
parent e1cc87afa3
commit 455b75e41d
2 changed files with 77 additions and 21 deletions
+35 -18
View File
@@ -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';
@@ -62,7 +50,6 @@ export class SocketConnection implements IConnection
private createSocket(socketUrl: string): void
{
this._dataBuffer = new ArrayBuffer(0);
const cryptoEnabled = !!GetConfiguration().getValue<boolean>('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<void>
{
const serverPubkeySpki = parseServerHello(frame);
const { pubkeySpki: serverPubkeySpki, signature } = parseServerHello(frame);
const signingRequired = !!GetConfiguration().getValue<boolean>('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<CryptoKey>
{
if(this._cachedSigningPublicKey) return this._cachedSigningPublicKey;
const pinned = GetConfiguration().getValue<string>('crypto.ws.signing.public_key', '');
if(pinned)
{
this._cachedSigningPublicKey = await importSigningPublicKeyFromBase64(pinned);
return this._cachedSigningPublicKey;
}
const endpointTemplate = GetConfiguration().getValue<string>('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<ArrayBuffer>
{
if(frame.byteLength < NONCE_LEN + 16) throw new Error('encrypted frame too short');
@@ -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<CryptoKey>
{
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<boolean>
{
return window.crypto.subtle.verify(
{ name: 'ECDSA', hash: 'SHA-256' },
signingKey,
signature,
signedBytes
);
}