diff --git a/package-lock.json b/package-lock.json index f785a643..bc33475b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,7 +10,7 @@ "license": "MIT", "dependencies": { "@csfloat/cs2-inspect-serializer": "^1.1.0", - "@csfloat/tlsn-wasm": "0.1.0-alpha.14", + "@csfloat/tlsn-wasm": "0.1.0-alpha.15", "buffer": "^6.0.3", "comlink": "^4.4.2" }, @@ -66,7 +66,9 @@ } }, "node_modules/@csfloat/tlsn-wasm": { - "version": "0.1.0-alpha.14", + "version": "0.1.0-alpha.15", + "resolved": "https://registry.npmjs.org/@csfloat/tlsn-wasm/-/tlsn-wasm-0.1.0-alpha.15.tgz", + "integrity": "sha512-9U2+AjgiBosd6ck1Z0sdmZ+w2jfRH8DBxmNbsYQrirRz/9ihJg2cy3kd4MjXQnwEPvL969aLNskzl/9YUklkvA==", "license": "MIT OR Apache-2.0" }, "node_modules/@discoveryjs/json-ext": { @@ -618,9 +620,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -638,9 +637,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -658,9 +654,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -678,9 +671,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -698,9 +688,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -718,9 +705,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -3209,9 +3193,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3233,9 +3214,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3257,9 +3235,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MPL-2.0", "optional": true, "os": [ @@ -3281,9 +3256,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MPL-2.0", "optional": true, "os": [ diff --git a/package.json b/package.json index dedb27f6..4c7bf8ad 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ }, "dependencies": { "@csfloat/cs2-inspect-serializer": "^1.1.0", - "@csfloat/tlsn-wasm": "0.1.0-alpha.14", + "@csfloat/tlsn-wasm": "0.1.0-alpha.15", "buffer": "^6.0.3", "comlink": "^4.4.2" } diff --git a/src/environment.dev.ts b/src/environment.dev.ts index d715f4ec..feed5bff 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -1,8 +1,8 @@ export const environment = { csfloat_base_api_url: 'http://localhost:8080/api', notary: { - tlsn: 'https://notary.csfloat.com', - ws: 'wss://notary.csfloat.com/proxy', + tlsn: 'http://localhost:7047', + ws: 'ws://localhost:7047/proxy', loggingLevel: 'Error', }, reverse_watch_base_api_url: 'http://localhost:3434/api', diff --git a/src/environment.staging.ts b/src/environment.staging.ts index a35422eb..a5fd5eef 100644 --- a/src/environment.staging.ts +++ b/src/environment.staging.ts @@ -1,8 +1,8 @@ export const environment = { csfloat_base_api_url: 'https://csfloat.build/api', notary: { - tlsn: 'https://notary.csfloat.com', - ws: 'wss://notary.csfloat.com/proxy', + tlsn: 'https://notary.csfloat.com/v15', + ws: 'wss://notary.csfloat.com/v15/proxy', loggingLevel: 'Error', }, reverse_watch_base_api_url: 'https://reverse.watch/api', diff --git a/src/environment.ts b/src/environment.ts index a81c07ec..8879f056 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -1,8 +1,8 @@ export const environment = { csfloat_base_api_url: 'https://csfloat.com/api', notary: { - tlsn: 'https://notary.csfloat.com', - ws: 'wss://notary.csfloat.com/proxy', + tlsn: 'https://notary.csfloat.com/v15', + ws: 'wss://notary.csfloat.com/v15/proxy', loggingLevel: 'Warn', }, reverse_watch_base_api_url: 'https://reverse.watch/api', diff --git a/src/lib/notary/types.ts b/src/lib/notary/types.ts index 40b3fa0f..62384f8d 100644 --- a/src/lib/notary/types.ts +++ b/src/lib/notary/types.ts @@ -1,5 +1,7 @@ import {NetworkSetting} from '@csfloat/tlsn-wasm'; +export type NotaryProverMode = 'proxy' | 'mpc'; + export enum ProofType { TRADE_OFFERS = 'trade_offers', TRADE_OFFER = 'trade_offer', @@ -71,6 +73,9 @@ export type NotaryProveRequest = { // Optionally override the network setting for proving network_setting?: NetworkSetting; + + // Proxy-TLS is the default. MPC retains the legacy browser TCP-proxy flow. + prover_mode?: NotaryProverMode; }; } & ProveRequestPayloads[T]; }[ProofType]; diff --git a/src/offscreen/handlers/notary_prove.ts b/src/offscreen/handlers/notary_prove.ts index 73cc2b39..e65ab635 100644 --- a/src/offscreen/handlers/notary_prove.ts +++ b/src/offscreen/handlers/notary_prove.ts @@ -7,10 +7,11 @@ import { import {getSteamRequestURL} from '../../lib/notary/utils'; import * as Comlink from 'comlink'; import {environment} from '../../environment'; -import type {LoggingLevel, Method, Prover as TProver, Reveal} from '@csfloat/tlsn-wasm'; +import type {IoChannel, LoggingLevel, Method, Prover as TProver, ProverMode, Reveal} from '@csfloat/tlsn-wasm'; import {NotarySessionClient} from './notary_session_client'; import {Remote} from 'comlink'; import {wait} from '../../lib/utils/snips'; +import {WebSocketIoChannel} from '../io_channel'; const {init, Prover}: any = Comlink.wrap(new Worker(new URL('../worker.ts', import.meta.url))); @@ -65,14 +66,24 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< const session = await NotarySessionClient.create(maxRecvData, maxSentData, maybeNotaryToken); + let verifierChannel: WebSocketIoChannel | undefined; + let serverChannel: WebSocketIoChannel | undefined; try { + const proverMode: ProverMode = request.notary_request.meta?.prover_mode === 'mpc' ? 'Mpc' : 'Proxy'; + // Create and setup prover const prover = (await new Prover({ server_name: 'api.steampowered.com', + mode: proverMode, max_recv_data: maxRecvData, max_sent_data: maxSentData, + max_sent_records: undefined, + max_recv_data_online: undefined, + max_recv_records_online: undefined, network: request.notary_request.meta?.network_setting ?? 'Latency', defer_decryption_from_start: true, + client_auth: undefined, + root_certs: undefined, })) as Remote; let verifierUrl = `${environment.notary.tlsn}/verifier?sessionId=${session.getID()}`; @@ -80,7 +91,8 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< verifierUrl += `&token=${maybeNotaryToken}`; } - await prover.setup(verifierUrl); + verifierChannel = await WebSocketIoChannel.connect(toWebSocketUrl(verifierUrl)); + await prover.setup(Comlink.proxy(verifierChannel)); // Convert headers to Map for WASM const headerMap = new Map(); @@ -88,13 +100,17 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< headerMap.set(key, Buffer.from(value).toJSON().data); } - let wsUrl = `${environment.notary.ws}`; - if (maybeNotaryToken) { - wsUrl += `?token=${maybeNotaryToken}`; + let serverIo: IoChannel | undefined; + if (proverMode === 'Mpc') { + let wsUrl = `${environment.notary.ws}`; + if (maybeNotaryToken) { + wsUrl += `?token=${maybeNotaryToken}`; + } + serverChannel = await WebSocketIoChannel.connect(wsUrl); + serverIo = Comlink.proxy(serverChannel); } - // Send HTTP request via proxy - await prover.send_request(wsUrl, { + await prover.send_request(serverIo, { uri: serverURL, method: 'GET' as Method, headers: headerMap, @@ -118,6 +134,7 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< return await completedPromise; } finally { + await Promise.allSettled([verifierChannel?.close(), serverChannel?.close()]); session.close(); } }, @@ -129,6 +146,10 @@ export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< } ); +function toWebSocketUrl(url: string): string { + return url.replace(/^http:/, 'ws:').replace(/^https:/, 'wss:'); +} + /** * Estimates the total request byte size over the wire if sent over HTTP 1.1 * diff --git a/src/offscreen/io_channel.test.ts b/src/offscreen/io_channel.test.ts new file mode 100644 index 00000000..74306a61 --- /dev/null +++ b/src/offscreen/io_channel.test.ts @@ -0,0 +1,88 @@ +import {afterEach, beforeEach, describe, expect, it} from 'vitest'; +import {WebSocketIoChannel} from './io_channel'; + +class FakeWebSocket extends EventTarget { + static readonly CONNECTING = 0; + static readonly OPEN = 1; + static readonly CLOSING = 2; + static readonly CLOSED = 3; + static latest?: FakeWebSocket; + + readonly sent: Uint8Array[] = []; + binaryType: BinaryType = 'blob'; + readyState = FakeWebSocket.CONNECTING; + + constructor(readonly url: string) { + super(); + FakeWebSocket.latest = this; + } + + open(): void { + this.readyState = FakeWebSocket.OPEN; + this.dispatchEvent(new Event('open')); + } + + message(data: ArrayBuffer): void { + const event = new Event('message'); + Object.defineProperty(event, 'data', {value: data}); + this.dispatchEvent(event); + } + + send(data: ArrayBufferView | ArrayBuffer): void { + const bytes = + data instanceof ArrayBuffer + ? new Uint8Array(data) + : new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + this.sent.push(new Uint8Array(bytes)); + } + + close(): void { + if (this.readyState === FakeWebSocket.CLOSED) { + return; + } + this.readyState = FakeWebSocket.CLOSED; + this.dispatchEvent(new Event('close')); + } +} + +const originalWebSocket = globalThis.WebSocket; + +describe('WebSocketIoChannel', () => { + beforeEach(() => { + FakeWebSocket.latest = undefined; + globalThis.WebSocket = FakeWebSocket as unknown as typeof WebSocket; + }); + + afterEach(() => { + globalThis.WebSocket = originalWebSocket; + }); + + it('queues binary messages and returns EOF on close', async () => { + const channelPromise = WebSocketIoChannel.connect('wss://notary.test/verifier'); + const socket = FakeWebSocket.latest!; + socket.open(); + const channel = await channelPromise; + + socket.message(new Uint8Array([1, 2, 3]).buffer); + await expect(channel.read()).resolves.toEqual(new Uint8Array([1, 2, 3])); + + const pendingRead = channel.read(); + socket.close(); + await expect(pendingRead).resolves.toBeNull(); + await expect(channel.read()).resolves.toBeNull(); + }); + + it('preserves write order and rejects writes after close', async () => { + const channelPromise = WebSocketIoChannel.connect('wss://notary.test/verifier'); + const socket = FakeWebSocket.latest!; + socket.open(); + const channel = await channelPromise; + + await channel.write(new Uint8Array([1])); + await channel.write(new Uint8Array([2])); + expect(socket.sent).toEqual([new Uint8Array([1]), new Uint8Array([2])]); + + await channel.close(); + await expect(channel.write(new Uint8Array([3]))).rejects.toThrow('closed'); + }); +}); diff --git a/src/offscreen/io_channel.ts b/src/offscreen/io_channel.ts new file mode 100644 index 00000000..32c06be9 --- /dev/null +++ b/src/offscreen/io_channel.ts @@ -0,0 +1,146 @@ +import type {IoChannel} from '@csfloat/tlsn-wasm'; + +type PendingRead = { + resolve: (value: Uint8Array | null) => void; + reject: (reason: Error) => void; +}; + +/** + * Adapts a browser WebSocket to the duplex channel expected by TLSNotary. + */ +export class WebSocketIoChannel implements IoChannel { + private readonly reads: Uint8Array[] = []; + private readonly pendingReads: PendingRead[] = []; + private failure?: Error; + private closed = false; + private messageChain = Promise.resolve(); + + private constructor(private readonly socket: WebSocket) { + socket.binaryType = 'arraybuffer'; + socket.addEventListener('message', (event) => { + this.messageChain = this.messageChain + .then(async () => this.enqueue(await normalizeMessage(event.data))) + .catch((error: unknown) => this.fail(asError(error))); + }); + socket.addEventListener('error', () => this.fail(new Error('WebSocket I/O channel failed'))); + socket.addEventListener('close', () => this.finish()); + } + + static connect(url: string): Promise { + return new Promise((resolve, reject) => { + const socket = new WebSocket(url); + + const onOpen = () => { + cleanup(); + resolve(new WebSocketIoChannel(socket)); + }; + const onError = () => { + cleanup(); + socket.close(); + reject(new Error(`Failed to open WebSocket I/O channel: ${url}`)); + }; + const cleanup = () => { + socket.removeEventListener('open', onOpen); + socket.removeEventListener('error', onError); + }; + + socket.addEventListener('open', onOpen); + socket.addEventListener('error', onError); + }); + } + + read(): Promise { + const queued = this.reads.shift(); + if (queued) { + return Promise.resolve(queued); + } + if (this.failure) { + return Promise.reject(this.failure); + } + if (this.closed) { + return Promise.resolve(null); + } + + return new Promise((resolve, reject) => { + this.pendingReads.push({resolve, reject}); + }); + } + + async write(data: Uint8Array): Promise { + if (this.failure) { + throw this.failure; + } + if (this.closed || this.socket.readyState !== WebSocket.OPEN) { + throw new Error('Cannot write to a closed WebSocket I/O channel'); + } + + this.socket.send(data); + } + + close(): Promise { + if (this.closed || this.socket.readyState === WebSocket.CLOSED) { + this.finish(); + return Promise.resolve(); + } + + return new Promise((resolve) => { + this.socket.addEventListener('close', () => resolve(), {once: true}); + this.socket.close(); + }); + } + + private enqueue(data: Uint8Array): void { + if (this.closed || this.failure) { + return; + } + + const pending = this.pendingReads.shift(); + if (pending) { + pending.resolve(data); + } else { + this.reads.push(data); + } + } + + private fail(error: Error): void { + if (this.failure || this.closed) { + return; + } + + this.failure = error; + for (const pending of this.pendingReads.splice(0)) { + pending.reject(error); + } + if (this.socket.readyState === WebSocket.OPEN || this.socket.readyState === WebSocket.CONNECTING) { + this.socket.close(); + } + } + + private finish(): void { + if (this.closed) { + return; + } + + this.closed = true; + for (const pending of this.pendingReads.splice(0)) { + pending.resolve(null); + } + } +} + +async function normalizeMessage(data: unknown): Promise { + if (data instanceof ArrayBuffer) { + return new Uint8Array(data); + } + if (ArrayBuffer.isView(data)) { + return new Uint8Array(data.buffer, data.byteOffset, data.byteLength); + } + if (typeof Blob !== 'undefined' && data instanceof Blob) { + return new Uint8Array(await data.arrayBuffer()); + } + throw new Error('TLSNotary WebSocket received a non-binary message'); +} + +function asError(error: unknown): Error { + return error instanceof Error ? error : new Error(String(error)); +}