diff --git a/manifest.json b/manifest.json index 6728e49a..47cfd4d7 100644 --- a/manifest.json +++ b/manifest.json @@ -48,7 +48,8 @@ "service_worker": "src/background.js", "type": "module" }, - "permissions": ["storage", "scripting", "alarms", "declarativeNetRequestWithHostAccess"], + "minimum_chrome_version": "109", + "permissions": ["storage", "scripting", "alarms", "declarativeNetRequestWithHostAccess", "offscreen"], "host_permissions": [ "*://*.steamcommunity.com/market/listings/730/*", "*://*.steamcommunity.com/id/*/inventory*", @@ -73,5 +74,8 @@ "path": "src/steamcommunity_ruleset.json" } ] + }, + "content_security_policy": { + "extension_pages": "script-src 'self' 'wasm-unsafe-eval'; object-src 'self';" } } diff --git a/package-lock.json b/package-lock.json index 3792540c..55ce4e70 100644 --- a/package-lock.json +++ b/package-lock.json @@ -8,6 +8,11 @@ "name": "csfloat-extension", "version": "5.8.1", "license": "MIT", + "dependencies": { + "buffer": "^6.0.3", + "comlink": "^4.4.2", + "tlsn-js": "0.1.0-alpha.12.0" + }, "devDependencies": { "@eslint/compat": "^1.2.8", "@eslint/eslintrc": "^3.3.1", @@ -1193,6 +1198,26 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "node_modules/base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT" + }, "node_modules/big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -1258,6 +1283,30 @@ "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" } }, + "node_modules/buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "MIT", + "dependencies": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "node_modules/buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -1396,6 +1445,12 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "node_modules/comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==", + "license": "Apache-2.0" + }, "node_modules/commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -2501,6 +2556,26 @@ "postcss": "^8.1.0" } }, + "node_modules/ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/feross" + }, + { + "type": "patreon", + "url": "https://www.patreon.com/feross" + }, + { + "type": "consulting", + "url": "https://feross.org/support" + } + ], + "license": "BSD-3-Clause" + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -4098,6 +4173,24 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/tlsn-js": { + "version": "0.1.0-alpha.12.0", + "resolved": "https://registry.npmjs.org/tlsn-js/-/tlsn-js-0.1.0-alpha.12.0.tgz", + "integrity": "sha512-un2ImvRjZ8d3BypReHJkC58NV1JPIppIhDrPLeR75+1uSsv805fSBruBtIYHklnLZQX38iyMJdDf+BEz7Z4nZQ==", + "license": "ISC", + "dependencies": { + "tlsn-wasm": "0.1.0-alpha.12" + }, + "engines": { + "node": ">= 16.20.2" + } + }, + "node_modules/tlsn-wasm": { + "version": "0.1.0-alpha.12", + "resolved": "https://registry.npmjs.org/tlsn-wasm/-/tlsn-wasm-0.1.0-alpha.12.tgz", + "integrity": "sha512-0HlhM466ewogualMmpevFAgfWfUh1qwt/RjbOKSQiE+EPK99x8BrMBlChAxjnCxWpuUaDfaVEXTEPF07RYBtuQ==", + "license": "MIT OR Apache-2.0" + }, "node_modules/to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", @@ -5385,6 +5478,11 @@ "integrity": "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==", "dev": true }, + "base64-js": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz", + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, "big.js": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/big.js/-/big.js-5.2.2.tgz", @@ -5421,6 +5519,15 @@ "update-browserslist-db": "^1.1.1" } }, + "buffer": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/buffer/-/buffer-6.0.3.tgz", + "integrity": "sha512-FTiCpNxtwiZZHEZbcbTIcZjERVICn9yq/pDFkTl95/AxzD1naBctN7YO68riM/gLSDY7sdrMby8hofADYuuqOA==", + "requires": { + "base64-js": "^1.3.1", + "ieee754": "^1.2.1" + } + }, "buffer-from": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/buffer-from/-/buffer-from-1.1.2.tgz", @@ -5517,6 +5624,11 @@ "integrity": "sha512-3tlv/dIP7FWvj3BsbHrGLJ6l/oKh1O3TcgBqMn+yyCagOxc23fyzDS6HypQbgxWbkpDnf52p1LuR4eWDQ/K9WQ==", "dev": true }, + "comlink": { + "version": "4.4.2", + "resolved": "https://registry.npmjs.org/comlink/-/comlink-4.4.2.tgz", + "integrity": "sha512-OxGdvBmJuNKSCMO4NTl1L47VRp6xn2wG4F/2hYzB6tiCb709otOxtEYCSvK80PtjODfXXZu8ds+Nw5kVCjqd2g==" + }, "commander": { "version": "10.0.1", "resolved": "https://registry.npmjs.org/commander/-/commander-10.0.1.tgz", @@ -6247,6 +6359,11 @@ "dev": true, "requires": {} }, + "ieee754": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz", + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -7278,6 +7395,19 @@ } } }, + "tlsn-js": { + "version": "0.1.0-alpha.12.0", + "resolved": "https://registry.npmjs.org/tlsn-js/-/tlsn-js-0.1.0-alpha.12.0.tgz", + "integrity": "sha512-un2ImvRjZ8d3BypReHJkC58NV1JPIppIhDrPLeR75+1uSsv805fSBruBtIYHklnLZQX38iyMJdDf+BEz7Z4nZQ==", + "requires": { + "tlsn-wasm": "0.1.0-alpha.12" + } + }, + "tlsn-wasm": { + "version": "0.1.0-alpha.12", + "resolved": "https://registry.npmjs.org/tlsn-wasm/-/tlsn-wasm-0.1.0-alpha.12.tgz", + "integrity": "sha512-0HlhM466ewogualMmpevFAgfWfUh1qwt/RjbOKSQiE+EPK99x8BrMBlChAxjnCxWpuUaDfaVEXTEPF07RYBtuQ==" + }, "to-regex-range": { "version": "5.0.1", "resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz", diff --git a/package.json b/package.json index b9f3767d..6b98d845 100644 --- a/package.json +++ b/package.json @@ -66,5 +66,10 @@ "typescript": "^5.8.3", "webpack": "^5.98.0", "webpack-cli": "^6.0.1" + }, + "dependencies": { + "buffer": "^6.0.3", + "comlink": "^4.4.2", + "tlsn-js": "0.1.0-alpha.12.0" } } diff --git a/src/environment.dev.ts b/src/environment.dev.ts index 30f27570..0cbbd61c 100644 --- a/src/environment.dev.ts +++ b/src/environment.dev.ts @@ -1,3 +1,8 @@ export const environment = { csfloat_base_api_url: 'http://localhost:8080/api', + notary: { + tlsn: 'https://notary.csfloat.com/tlsn/', + ws: 'wss://notary.csfloat.com/ws/', + loggingLevel: 'Error', + }, }; diff --git a/src/environment.ts b/src/environment.ts index a2ee28c3..7bfb711a 100644 --- a/src/environment.ts +++ b/src/environment.ts @@ -1,3 +1,8 @@ export const environment = { csfloat_base_api_url: 'https://csfloat.com/api', + notary: { + tlsn: 'https://notary.csfloat.com/tlsn/', + ws: 'wss://notary.csfloat.com/ws/', + loggingLevel: 'Warn', + }, }; diff --git a/src/lib/bridge/handlers/handlers.ts b/src/lib/bridge/handlers/handlers.ts index 2eba0125..a2082866 100644 --- a/src/lib/bridge/handlers/handlers.ts +++ b/src/lib/bridge/handlers/handlers.ts @@ -33,6 +33,7 @@ import {FetchCSFloatMe} from './fetch_csfloat_me'; import {PingRollbackTrade} from './ping_rollback_trade'; import {FetchTradeHistory} from './fetch_trade_history'; import {FetchSlimTrades} from './fetch_slim_trades'; +import {NotaryProve} from './notary_prove'; export const HANDLERS_MAP: {[key in RequestType]: RequestHandler} = { [RequestType.EXECUTE_SCRIPT_ON_PAGE]: ExecuteScriptOnPage, @@ -68,4 +69,5 @@ export const HANDLERS_MAP: {[key in RequestType]: RequestHandler} = { [RequestType.PING_ROLLBACK_TRADE]: PingRollbackTrade, [RequestType.FETCH_TRADE_HISTORY]: FetchTradeHistory, [RequestType.FETCH_SLIM_TRADES]: FetchSlimTrades, + [RequestType.NOTARY_PROVE]: NotaryProve, }; diff --git a/src/lib/bridge/handlers/notary_prove.ts b/src/lib/bridge/handlers/notary_prove.ts new file mode 100644 index 00000000..d74d5006 --- /dev/null +++ b/src/lib/bridge/handlers/notary_prove.ts @@ -0,0 +1,50 @@ +import {SimpleHandler} from './main'; +import {RequestType} from './types'; +import {SendToOffscreen} from '../../../offscreen/client'; +import {HasPermissions} from './has_permissions'; +import {NotaryProveRequest} from '../../notary/types'; +import {getAccessToken} from '../../alarms/access_token'; +import {PresentationJSON} from 'tlsn-js/build/types'; +import { + OffscreenRequestType, + TLSNProveOffscreenRequest, + TLSNProveOffscreenResponse, +} from '../../../offscreen/handlers/types'; +import {MaxConcurrency} from '../wrappers/cached'; + +export interface NotaryProveResponse { + presentation: PresentationJSON; +} + +export const NotaryProve = MaxConcurrency( + new SimpleHandler( + RequestType.NOTARY_PROVE, + async (request: NotaryProveRequest): Promise => { + const steamPoweredPermissions = await HasPermissions.handleRequest( + { + permissions: [], + origins: ['https://api.steampowered.com/*'], + }, + {} + ); + if (!steamPoweredPermissions.granted) { + throw new Error('must have api.steampowered.com permissions in order to prove API requests'); + } + + const access_token = await getAccessToken(request.expected_steam_id); + + const response = await SendToOffscreen( + OffscreenRequestType.TLSN_PROVE, + { + notary_request: request, + access_token, + } + ); + + return { + presentation: response.presentation, + }; + } + ), + 2 +); diff --git a/src/lib/bridge/handlers/types.ts b/src/lib/bridge/handlers/types.ts index 9a6dce7c..18c9f7ea 100644 --- a/src/lib/bridge/handlers/types.ts +++ b/src/lib/bridge/handlers/types.ts @@ -32,4 +32,5 @@ export enum RequestType { PING_ROLLBACK_TRADE = 30, FETCH_TRADE_HISTORY = 31, FETCH_SLIM_TRADES = 32, + NOTARY_PROVE = 33, } diff --git a/src/lib/bridge/wrappers/cached.ts b/src/lib/bridge/wrappers/cached.ts index 96ed1bd2..0a407113 100644 --- a/src/lib/bridge/wrappers/cached.ts +++ b/src/lib/bridge/wrappers/cached.ts @@ -75,3 +75,14 @@ export class CachedHandler implements RequestHandler { return this.queue.fetch(request, sender); } } + +/** + * Sets max concurrency for the given handler. Requests in excess of this limit will block until the active + * requests are finished. Uses a FIFO queue underneath. + * + * @param handler Handler to wrap with a max concurrency + * @param maxConcurrency Max concurrency + */ +export function MaxConcurrency(handler: RequestHandler, maxConcurrency: number) { + return new CachedHandler(handler, maxConcurrency, /* disable caching */ 0); +} diff --git a/src/lib/notary/types.ts b/src/lib/notary/types.ts new file mode 100644 index 00000000..521cfd5f --- /dev/null +++ b/src/lib/notary/types.ts @@ -0,0 +1,48 @@ +export enum ProofType { + TRADE_OFFERS = 'trade_offers', + TRADE_OFFER = 'trade_offer', + TRADE_HISTORY = 'trade_history', + TRADE_STATUS = 'trade_status', +} + +interface ProveRequestPayloads { + // IEconService/GetTradeOffers/v1/ + [ProofType.TRADE_OFFERS]: { + get_sent_offers?: boolean; + get_received_offers?: boolean; + get_descriptions?: boolean; + language?: string; + active_only?: boolean; + historical_only?: boolean; + time_historical_cutoff?: number; + cursor?: number; + }; + // IEconService/GetTradeOffer/v1/ + [ProofType.TRADE_OFFER]: { + tradeofferid: string; + }; + // IEconService/GetTradeHistory/v1/ + [ProofType.TRADE_HISTORY]: { + max_trades: number; + start_after_time?: number; + start_after_tradeid?: string; + navigating_back?: boolean; + get_descriptions?: boolean; + language?: string; + include_failed?: boolean; + include_total?: boolean; + }; + // IEconService/GetTradeStatus/v1/ + [ProofType.TRADE_STATUS]: { + tradeid: string; + get_descriptions?: boolean; + language?: string; + }; +} + +export type NotaryProveRequest = { + [T in ProofType]: { + type: T; + expected_steam_id?: string; + } & ProveRequestPayloads[T]; +}[ProofType]; diff --git a/src/lib/notary/utils.ts b/src/lib/notary/utils.ts new file mode 100644 index 00000000..c0f72804 --- /dev/null +++ b/src/lib/notary/utils.ts @@ -0,0 +1,38 @@ +import {NotaryProveRequest, ProofType} from './types'; +import {AccessToken} from '../alarms/access_token'; + +const PROOF_BASE_URLS: Record = { + [ProofType.TRADE_OFFERS]: 'https://api.steampowered.com/IEconService/GetTradeOffers/v1/', + [ProofType.TRADE_OFFER]: 'https://api.steampowered.com/IEconService/GetTradeOffer/v1/', + [ProofType.TRADE_HISTORY]: 'https://api.steampowered.com/IEconService/GetTradeHistory/v1/', + [ProofType.TRADE_STATUS]: 'https://api.steampowered.com/IEconService/GetTradeStatus/v1/', +}; + +function buildQueryString(params: Record): string { + const query = Object.entries(params) + .filter(([, value]) => value !== undefined) + .sort(([keyA], [keyB]) => { + // Always sort the access_token to be the last parameter for cleanliness + if (keyA === 'access_token') return 1; + if (keyB === 'access_token') return -1; + return 0; + }) + .map(([key, value]) => `${encodeURIComponent(key)}=${encodeURIComponent(String(value))}`) + .join('&'); + + return query ? `?${query}` : ''; +} + +/** + * Get Full Steam URL to request for the given notary request + * + * @param request Notary request you want the corresponding request URL for + * @param access_token Corresponding user "access token" to use in the request + */ +export function getSteamRequestURL(request: NotaryProveRequest, access_token: AccessToken): string { + // Separate the 'type' property from the actual URL parameters + const {type, ...params} = request; + const baseUrl = PROOF_BASE_URLS[type]; + const queryString = buildQueryString(Object.assign(params, {access_token: access_token.token})); + return `${baseUrl}${queryString}`; +} diff --git a/src/lib/utils/snips.ts b/src/lib/utils/snips.ts index e5398617..8640a386 100644 --- a/src/lib/utils/snips.ts +++ b/src/lib/utils/snips.ts @@ -1,3 +1,7 @@ export function inPageContext() { return typeof chrome === 'undefined' || !chrome.extension; } + +export function wait(ms: number) { + return new Promise((resolve) => setTimeout(resolve, ms)); +} diff --git a/src/offscreen/client.ts b/src/offscreen/client.ts new file mode 100644 index 00000000..49e2e492 --- /dev/null +++ b/src/offscreen/client.ts @@ -0,0 +1,25 @@ +import {OffscreenRequestBundle, OffscreenResponseBundle} from './types'; +import {OffscreenRequestType} from './handlers/types'; +import {closeOffscreenDocument, openOffscreenDocument} from '../offscreen/utils'; + +export async function SendToOffscreen(requestType: OffscreenRequestType, args: Req): Promise { + await openOffscreenDocument(); + + const bundle: OffscreenRequestBundle = { + type: requestType, + target: 'offscreen', + data: args, + }; + + const response: OffscreenResponseBundle = await chrome.runtime.sendMessage(bundle); + + if (response.shouldClose) { + await closeOffscreenDocument(); + } + + if (response.error) { + throw new Error(response.error); + } + + return response.data; +} diff --git a/src/offscreen/handlers/handlers.ts b/src/offscreen/handlers/handlers.ts new file mode 100644 index 00000000..667e2a5a --- /dev/null +++ b/src/offscreen/handlers/handlers.ts @@ -0,0 +1,6 @@ +import {OffscreenRequestHandler, OffscreenRequestType} from './types'; +import {TLSNProveOffscreenHandler} from './notary_prove'; + +export const OFFSCREEN_HANDLERS_MAP: {[key in OffscreenRequestType]: OffscreenRequestHandler} = { + [OffscreenRequestType.TLSN_PROVE]: TLSNProveOffscreenHandler, +}; diff --git a/src/offscreen/handlers/notary_prove.ts b/src/offscreen/handlers/notary_prove.ts new file mode 100644 index 00000000..37f8b31e --- /dev/null +++ b/src/offscreen/handlers/notary_prove.ts @@ -0,0 +1,181 @@ +import { + ClosableOffscreenHandler, + OffscreenRequestType, + TLSNProveOffscreenRequest, + TLSNProveOffscreenResponse, +} from './types'; +import {getSteamRequestURL} from '../../lib/notary/utils'; +import * as Comlink from 'comlink'; +import {environment} from '../../environment'; +import { + Prover as TProver, + Presentation as TPresentation, + Commit, + NotaryServer, + mapStringToRange, + subtractRanges, +} from 'tlsn-js'; +const {init, Prover, Presentation}: any = Comlink.wrap(new Worker(new URL('../worker.ts', import.meta.url))); + +export async function initThreads() { + await init({ + loggingLevel: environment.notary.loggingLevel, + hardwareConcurrency: navigator.hardwareConcurrency, + }); +} + +let totalProveRequests = 0; + +export const TLSNProveOffscreenHandler = new ClosableOffscreenHandler< + TLSNProveOffscreenRequest, + TLSNProveOffscreenResponse +>( + OffscreenRequestType.TLSN_PROVE, + async (request) => { + totalProveRequests++; + + const serverURL = getSteamRequestURL(request.notary_request, request.access_token); + + // Headers that will be sent with the original request to Steam + // This MUST accurately depict the headers that the browser will send, + // otherwise the max sent bytes will be off + const headers = { + Connection: 'close', + Host: 'api.steampowered.com', + 'Accept-Encoding': 'gzip', + }; + + const maxSentData = calculateRequestSize(serverURL, 'GET', headers); + + const maxRecvData = await calculateResponseSize(serverURL, 'GET', headers); + + const notary = NotaryServer.from(environment.notary.tlsn); + + const prover = (await new Prover({ + serverDns: 'api.steampowered.com', + maxRecvData, + maxSentData, + })) as TProver; + + await prover.setup(await notary.sessionUrl()); + + await prover.sendRequest(environment.notary.ws, { + url: serverURL, + method: 'GET', + headers: { + 'Accept-Encoding': 'gzip', + }, + }); + + const transcript = await prover.transcript(); + const {sent, recv} = transcript; + + const commit: Commit = { + sent: subtractRanges( + {start: 0, end: sent.length}, + mapStringToRange([request.access_token.token], Buffer.from(sent).toString('utf-8')) + ), + recv: [ + // No secrets in response body + {start: 0, end: recv.length}, + ], + }; + const notarizationOutputs = await prover.notarize(commit); + + const presentation = (await new Presentation({ + attestationHex: notarizationOutputs.attestation, + secretsHex: notarizationOutputs.secrets, + notaryUrl: notarizationOutputs.notaryUrl, + websocketProxyUrl: notarizationOutputs.websocketProxyUrl, + reveal: {...commit, server_identity: false}, + })) as TPresentation; + + const presentationJSON = await presentation.json(); + + return { + presentation: presentationJSON, + }; + }, + () => { + // Require the offscreen to be re-initialized after every 5 prove requests + // Why? A hacky workaround a potential panic of thread counts overflowing as described in + // https://github.com/tlsnotary/tlsn/issues/959 + return totalProveRequests >= 5; + } +); + +/** + * Estimates the total request byte size over the wire if sent over HTTP 1.1 + * + * Of note, it accounts for a quirk in tlsn-js where the GET path includes the domain as well (which isn't needed) + * + * Adapted from https://github.com/tlsnotary/tlsn-js/issues/101 + * + * @param url Full request uRL including protocol, domain, path + * @param method HTTP method (ie. "GET") + * @param headers HTTP request headers + * @param body Optional request body + */ +function calculateRequestSize( + url: string, + method: 'GET' | 'POST', + headers: Record, + body?: string +): number { + const requestLineSize = new TextEncoder().encode(`${method} ${url} HTTP/1.1\r\n`).length; + + const headersSize = new TextEncoder().encode( + Object.entries(headers) + .map(([key, value]) => `${key}: ${value}\r\n`) // CRLF after each header + .join('') + ).length; + + const bodySize = body ? new TextEncoder().encode(JSON.stringify(body)).length : 0; + + return requestLineSize + headersSize + 2 + bodySize; // +2 for CRLF after headers +} + +/** + * Calculates the exact response byte size for the HTTP request by making the request itself and counting the response + * + * @param url Full request uRL including protocol, domain, path + * @param method HTTP method (ie. "GET") + * @param headers HTTP request headers + * @param body Optional request body + */ +async function calculateResponseSize( + url: string, + method: 'GET' | 'POST', + headers: Record, + body?: string +): Promise { + const opts: RequestInit = {method, headers}; + if (body) { + opts.body = body; + } + const response = await fetch(url, opts); + + const statusLine = `HTTP/1.1 ${response.status} ${response.statusText}`; + let headersSize = statusLine.length + 2; // +2 for CRLF (\r\n) + + response.headers.forEach((value, name) => { + headersSize += name.length + value.length + 4; // for ": " and "\r\n" + }); + + // Not included in fetch headers, but is in the network response + headersSize += 'Connection: close'.length + 2; + headersSize += 'X-N: S'.length + 2; + + // Add the final CRLF that separates the headers from the body. + headersSize += 2; + + const contentLength = response.headers.get('content-length'); + + if (!contentLength) { + throw new Error('no content length in response headers'); + } + + const bodySize = parseInt(contentLength, 10); + + return headersSize + bodySize; +} diff --git a/src/offscreen/handlers/types.ts b/src/offscreen/handlers/types.ts new file mode 100644 index 00000000..e77c3609 --- /dev/null +++ b/src/offscreen/handlers/types.ts @@ -0,0 +1,59 @@ +import MessageSender = chrome.runtime.MessageSender; +import {NotaryProveRequest} from '../../lib/notary/types'; +import {AccessToken} from '../../lib/alarms/access_token'; +import {PresentationJSON} from 'tlsn-js/build/types'; + +export enum OffscreenRequestType { + TLSN_PROVE = 1, +} + +export interface OffscreenRequestHandler { + handleRequest(request: Req, sender: MessageSender): Promise | Resp; + + getType(): OffscreenRequestType; + + // Whether to signal that the offscreen window should be closed after handling this request + shouldClose(): boolean; +} + +export class SimpleOffscreenHandler implements OffscreenRequestHandler { + constructor( + private type: OffscreenRequestType, + private handler: (request: Req, sender: MessageSender) => Promise | Resp + ) {} + + getType(): OffscreenRequestType { + return this.type; + } + + handleRequest(request: Req, sender: MessageSender): Promise | Resp { + return this.handler(request, sender); + } + + shouldClose(): boolean { + return false; + } +} + +export class ClosableOffscreenHandler extends SimpleOffscreenHandler { + constructor( + type: OffscreenRequestType, + handler: (request: Req, sender: MessageSender) => Promise | Resp, + private shouldCloseHandler: () => boolean + ) { + super(type, handler); + } + + shouldClose() { + return this.shouldCloseHandler(); + } +} + +export interface TLSNProveOffscreenRequest { + notary_request: NotaryProveRequest; + access_token: AccessToken; +} + +export interface TLSNProveOffscreenResponse { + presentation: PresentationJSON; +} diff --git a/src/offscreen/offscreen.html b/src/offscreen/offscreen.html new file mode 100644 index 00000000..fac0f71f --- /dev/null +++ b/src/offscreen/offscreen.html @@ -0,0 +1,10 @@ + + + + + Offscreen CSFloat + + + + + diff --git a/src/offscreen/offscreen.ts b/src/offscreen/offscreen.ts new file mode 100644 index 00000000..a58cc8bf --- /dev/null +++ b/src/offscreen/offscreen.ts @@ -0,0 +1,55 @@ +import {OffscreenRequestBundle, OffscreenResponseBundle} from './types'; +import {OFFSCREEN_HANDLERS_MAP} from './handlers/handlers'; +import {initThreads} from './handlers/notary_prove'; + +async function initialize() { + await initThreads(); + + async function handle( + request: OffscreenRequestBundle, + sender: chrome.runtime.MessageSender + ): Promise { + const handler = OFFSCREEN_HANDLERS_MAP[request.type]; + + if (!handler) { + throw new Error(`couldn't find handler for request type ${request.type}`); + } + + try { + const response = await handler.handleRequest(request.data, sender); + + return { + data: response, + shouldClose: handler.shouldClose(), + }; + } catch (e: any) { + console.error('Offscreen document error', e); + return { + error: e.message, + shouldClose: handler.shouldClose(), + }; + } + } + + chrome.runtime.onMessage.addListener((request: OffscreenRequestBundle, sender, sendResponse) => { + if (request.target !== 'offscreen') { + return; + } + + handle(request, sender) + .then((bundle) => { + sendResponse(bundle); + }) + .catch((e) => { + console.error('IRRECOVERABLE ERROR IN OFFSCREEN DURING REQUEST', e); + }); + + // Keep message channel open for async response + return true; + }); + + // Signal to service worker that offscreen is ready + chrome.runtime.sendMessage({type: 'offscreen_ready'}); +} + +initialize(); diff --git a/src/offscreen/types.ts b/src/offscreen/types.ts new file mode 100644 index 00000000..3123cd88 --- /dev/null +++ b/src/offscreen/types.ts @@ -0,0 +1,13 @@ +import {OffscreenRequestType} from './handlers/types'; + +export interface OffscreenRequestBundle { + type: OffscreenRequestType; + target: 'offscreen'; + data: any; +} + +export interface OffscreenResponseBundle { + data?: any; + error?: string; + shouldClose?: boolean; +} diff --git a/src/offscreen/utils.ts b/src/offscreen/utils.ts new file mode 100644 index 00000000..9c470535 --- /dev/null +++ b/src/offscreen/utils.ts @@ -0,0 +1,48 @@ +const OFFSCREEN_DOCUMENT_PATH = '/src/offscreen.html'; + +let creating: Promise | null; + +export async function openOffscreenDocument(): Promise { + const offscreenUrl = chrome.runtime.getURL(OFFSCREEN_DOCUMENT_PATH); + const hasExistingContext = await chrome.offscreen.hasDocument(); + + if (hasExistingContext) { + return; + } + + if (creating) { + await creating; + } else { + creating = (async () => { + const ready = new Promise((resolve) => { + const listener = (request: any, sender: chrome.runtime.MessageSender) => { + if (request?.type === 'offscreen_ready' && sender.url === offscreenUrl) { + chrome.runtime.onMessage.removeListener(listener); + resolve(); + } + }; + chrome.runtime.onMessage.addListener(listener); + }); + + await chrome.offscreen.createDocument({ + url: OFFSCREEN_DOCUMENT_PATH, + reasons: [chrome.offscreen.Reason.WORKERS], + justification: 'Workers for multi-threading', + }); + + await ready; + })(); + await creating; + creating = null; + } +} + +export async function closeOffscreenDocument(): Promise { + const hasExistingContext = await chrome.offscreen.hasDocument(); + + if (!hasExistingContext) { + return; + } + + await chrome.offscreen.closeDocument(); +} diff --git a/src/offscreen/worker.ts b/src/offscreen/worker.ts new file mode 100644 index 00000000..1d35aeed --- /dev/null +++ b/src/offscreen/worker.ts @@ -0,0 +1,9 @@ +import * as Comlink from 'comlink'; +import init, {Prover, Attestation, Presentation} from 'tlsn-js'; + +Comlink.expose({ + init, + Prover, + Presentation, + Attestation, +}); diff --git a/tsconfig.json b/tsconfig.json index 862036c0..77bfc273 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "strict": true, - "module": "es6", + "module": "es2022", "target": "es2018", "esModuleInterop": true, "sourceMap": true, diff --git a/webpack.config.js b/webpack.config.js index c2f97f76..0dc3257e 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -42,6 +42,9 @@ function convertToFirefoxManifest(manifest) { cp.host_permissions.push('*://*.csfloat.com/*'); // Force optional host permissions to be required cp.host_permissions = cp.host_permissions.concat(cp.optional_host_permissions); + // Not supported in Firefox + cp.permissions = cp.permissions.filter(e => e !== 'offscreen'); + return cp; } @@ -55,6 +58,7 @@ module.exports = (env) => { getPathEntries('./src/lib/types/*.d.ts'), getPathEntries('./src/background.ts'), getPathEntries('./src/popup/popup.ts'), + getPathEntries('./src/offscreen/offscreen.ts'), getPathEntries('./src/**/*.js') ), output: { @@ -106,6 +110,9 @@ module.exports = (env) => { {from: 'src', to: 'raw/', context: '.'}, {from: 'README.md', to: '', context: '.'}, {from: 'src/popup/popup.html', to: 'src/', context: '.'}, + {from: 'src/offscreen/offscreen.html', to: 'src/', context: '.'}, + {from: 'node_modules/tlsn-js/build/*.{wasm,js}', to: '[name][ext]'}, + {from: 'node_modules/tlsn-js/build/snippets', to: 'snippets/', context: '.'}, { from: 'manifest.json', to: 'manifest.json', @@ -119,6 +126,13 @@ module.exports = (env) => { if (mode === 'development') { // Add permissions only used for connecting to localhost dev env processed.host_permissions.push('http://localhost:8080/*'); + processed.host_permissions.push('http://localhost:4200/*'); + + // If you're running phoenix locally + processed.content_scripts.push({ + matches: ['*://*.localhost/*'], + js: ['src/lib/page_scripts/csfloat.js'], + }); const versionResource = processed.web_accessible_resources.find((e) => e.resources[0].includes('version.txt') @@ -153,6 +167,9 @@ module.exports = (env) => { test: /bluegem\.json$/, deleteOriginalAssets: true, }), + new webpack.ProvidePlugin({ + Buffer: ['buffer', 'Buffer'], + }), ], stats: { errorDetails: true, @@ -160,5 +177,15 @@ module.exports = (env) => { optimization: { usedExports: true, }, + // Required by wasm-bindgen-rayon, in order to use SharedArrayBuffer on the Web + // Ref: + // - https://github.com/GoogleChromeLabs/wasm-bindgen-rayon#setting-up + // - https://web.dev/articles/coop-coep + devServer: { + headers: { + 'Cross-Origin-Embedder-Policy': 'require-corp', + 'Cross-Origin-Opener-Policy': 'same-origin', + } + }, }; };