diff --git a/package-lock.json b/package-lock.json index 3c9949164a..131da3a94d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11009,6 +11009,12 @@ "node": ">= 0.8" } }, + "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": "14.0.2", "resolved": "https://registry.npmjs.org/commander/-/commander-14.0.2.tgz", @@ -27192,6 +27198,12 @@ "dev": true, "license": "MIT" }, + "node_modules/web-worker": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/web-worker/-/web-worker-1.5.0.tgz", + "integrity": "sha512-RiMReJrTAiA+mBjGONMnjVDP2u3p9R1vkcGz6gDIrOMT3oGuYwX2WRMYI9ipkphSuE5XKEhydbhNEJh4NY9mlw==", + "license": "Apache-2.0" + }, "node_modules/webidl-conversions": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", @@ -28125,11 +28137,11 @@ }, "packages/autocertifier-client": { "name": "@streamr/autocertifier-client", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/utils": "103.2.2", "eventemitter3": "^5.0.0", "node-forge": "^1.3.2" }, @@ -28225,14 +28237,14 @@ }, "packages/autocertifier-server": { "name": "@streamr/autocertifier-server", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@aws-sdk/client-route-53": "^3.936.0", - "@streamr/autocertifier-client": "103.2.0-experiment.1", - "@streamr/dht": "103.2.0-experiment.1", - "@streamr/proto-rpc": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/autocertifier-client": "103.2.2", + "@streamr/dht": "103.2.2", + "@streamr/proto-rpc": "103.2.2", + "@streamr/utils": "103.2.2", "acme-client": "^5.4.0", "body-parser": "^2.2.1", "dns2": "^2.1.0", @@ -28365,10 +28377,10 @@ }, "packages/cdn-location": { "name": "@streamr/cdn-location", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "Apache-2.0", "dependencies": { - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/utils": "103.2.2", "haversine": "^1.1.1" }, "devDependencies": { @@ -28463,15 +28475,15 @@ }, "packages/cli-tools": { "name": "@streamr/cli-tools", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "AGPL-3.0", "dependencies": { "@streamr/config": "^5.9.2", - "@streamr/dht": "103.2.0-experiment.1", + "@streamr/dht": "103.2.2", "@streamr/network-contracts": "^9.1.0", - "@streamr/sdk": "103.2.0-experiment.1", - "@streamr/trackerless-network": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/sdk": "103.2.2", + "@streamr/trackerless-network": "103.2.2", + "@streamr/utils": "103.2.2", "commander": "^14.0.2", "easy-table": "^1.1.1", "ethers": "^6.13.0", @@ -28483,7 +28495,7 @@ "streamr": "dist/bin/streamr.js" }, "devDependencies": { - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/event-stream": "^4.0.6", "@types/lodash": "^4.17.21", "@types/merge2": "^1.4.4", @@ -28493,17 +28505,17 @@ }, "packages/dht": { "name": "@streamr/dht", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@js-sdsl/ordered-map": "^4.4.2", "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/autocertifier-client": "103.2.0-experiment.1", - "@streamr/cdn-location": "103.2.0-experiment.1", - "@streamr/geoip-location": "103.2.0-experiment.1", - "@streamr/proto-rpc": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/autocertifier-client": "103.2.2", + "@streamr/cdn-location": "103.2.2", + "@streamr/geoip-location": "103.2.2", + "@streamr/proto-rpc": "103.2.2", + "@streamr/utils": "103.2.2", "eventemitter3": "^5.0.0", "heap": "^0.2.6", "ipaddr.js": "^2.0.1", @@ -28520,7 +28532,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@streamr/browser-test-runner": "^0.0.1", - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/heap": "^0.2.35", "@types/k-bucket": "^5.0.1", "@types/lodash": "^4.17.21", @@ -28631,10 +28643,10 @@ }, "packages/geoip-location": { "name": "@streamr/geoip-location", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "Apache-2.0", "dependencies": { - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/utils": "103.2.2", "eventemitter3": "^5.0.0", "long-timeout": "^0.1.1", "mmdb-lib": "^3.0.1", @@ -28735,14 +28747,14 @@ }, "packages/node": { "name": "@streamr/node", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@inquirer/prompts": "^7.9.0", "@streamr/config": "^5.9.2", - "@streamr/dht": "103.2.0-experiment.1", - "@streamr/sdk": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/dht": "103.2.2", + "@streamr/sdk": "103.2.2", + "@streamr/utils": "103.2.2", "aedes": "^0.51.3", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", @@ -28772,7 +28784,7 @@ "devDependencies": { "@inquirer/testing": "^2.1.51", "@streamr/network-contracts": "^9.1.0", - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/cors": "^2.8.19", "@types/express": "^5.0.1", "@types/heap": "^0.2.35", @@ -28804,12 +28816,12 @@ }, "packages/proto-rpc": { "name": "@streamr/proto-rpc", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "(Apache-2.0 AND BSD-3-Clause)", "dependencies": { "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/utils": "103.2.2", "eventemitter3": "^5.0.0", "lodash": "^4.17.21", "uuid": "^11.1.0" @@ -28817,7 +28829,7 @@ "devDependencies": { "@rollup/plugin-node-resolve": "^16.0.3", "@streamr/browser-test-runner": "^0.0.1", - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/lodash": "^4.17.21", "rimraf": "^6.1.2", "rollup": "^4.55.1", @@ -28912,18 +28924,19 @@ }, "packages/sdk": { "name": "@streamr/sdk", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "Apache-2.0", "dependencies": { "@noble/post-quantum": "^0.4.1", "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", "@streamr/config": "^5.9.2", - "@streamr/dht": "103.2.0-experiment.1", + "@streamr/dht": "103.2.2", "@streamr/network-contracts": "^9.1.0", - "@streamr/proto-rpc": "103.2.0-experiment.1", - "@streamr/trackerless-network": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/proto-rpc": "103.2.2", + "@streamr/trackerless-network": "103.2.2", + "@streamr/utils": "103.2.2", + "comlink": "^4.4.2", "env-paths": "^2.2.1", "ethers": "^6.13.0", "eventemitter3": "^5.0.0", @@ -28942,6 +28955,7 @@ "ts-toolbelt": "^9.6.0", "tsyringe": "^4.10.0", "uuid": "^11.1.0", + "web-worker": "^1.5.0", "zod": "^4.1.13" }, "devDependencies": { @@ -28951,7 +28965,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@rollup/plugin-terser": "^0.4.4", - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/heap": "^0.2.35", "@types/lodash": "^4.17.21", "ajv": "^8.17.1", @@ -29075,12 +29089,12 @@ }, "packages/test-utils": { "name": "@streamr/test-utils", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "Apache-2.0", "dependencies": { "@streamr/config": "^5.9.2", "@streamr/network-contracts": "^9.1.0", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/utils": "103.2.2", "cors": "^2.8.5", "ethers": "^6.13.0", "express": "^5.2.0", @@ -29181,14 +29195,14 @@ }, "packages/trackerless-network": { "name": "@streamr/trackerless-network", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "STREAMR NETWORK OPEN SOURCE LICENSE", "dependencies": { "@protobuf-ts/runtime": "^2.8.2", "@protobuf-ts/runtime-rpc": "^2.8.2", - "@streamr/dht": "103.2.0-experiment.1", - "@streamr/proto-rpc": "103.2.0-experiment.1", - "@streamr/utils": "103.2.0-experiment.1", + "@streamr/dht": "103.2.2", + "@streamr/proto-rpc": "103.2.2", + "@streamr/utils": "103.2.2", "eventemitter3": "^5.0.0", "lodash": "^4.17.21", "ts-essentials": "^10.1.1", @@ -29199,7 +29213,7 @@ "@rollup/plugin-json": "^6.1.0", "@rollup/plugin-node-resolve": "^16.0.3", "@streamr/browser-test-runner": "^0.0.1", - "@streamr/test-utils": "103.2.0-experiment.1", + "@streamr/test-utils": "103.2.2", "@types/lodash": "^4.17.21", "@types/yallist": "^5.0.0", "expect": "^30.0.5", @@ -29302,7 +29316,7 @@ }, "packages/utils": { "name": "@streamr/utils", - "version": "103.2.0-experiment.1", + "version": "103.2.2", "license": "Apache-2.0", "dependencies": { "@noble/curves": "^1.9.7", diff --git a/packages/browser-test-runner/src/createKarmaConfig.ts b/packages/browser-test-runner/src/createKarmaConfig.ts index b38a4ea05c..3f826cc4a1 100644 --- a/packages/browser-test-runner/src/createKarmaConfig.ts +++ b/packages/browser-test-runner/src/createKarmaConfig.ts @@ -4,8 +4,16 @@ import type { Configuration, ExternalItem } from 'webpack' const DEBUG_MODE = process.env.BROWSER_TEST_DEBUG_MODE ?? false +export interface KarmaConfigOptions { + // File patterns to serve but not include in the test bundle (e.g. worker files) + servedFiles?: string[] +} + export const createKarmaConfig = ( - testPaths: string[], webpackConfig: () => Configuration, localDirectory?: string + testPaths: string[], + webpackConfig: () => Configuration, + localDirectory?: string, + options: KarmaConfigOptions = {} ): (config: any) => any => { const setupFiles = [fileURLToPath(new URL('./karma-setup.js', import.meta.url))] @@ -57,7 +65,13 @@ export const createKarmaConfig = ( reporters: ['spec'], files: [ ...setupFiles, - ...testPaths + ...testPaths, + ...(options.servedFiles ?? []).map((pattern) => ({ + pattern, + included: false, + served: true, + watched: false + })) ], preprocessors, customLaunchers: { diff --git a/packages/browser-test-runner/src/exports.ts b/packages/browser-test-runner/src/exports.ts index da76c5cee4..e04593074b 100644 --- a/packages/browser-test-runner/src/exports.ts +++ b/packages/browser-test-runner/src/exports.ts @@ -1,2 +1,3 @@ export { createKarmaConfig } from './createKarmaConfig' +export type { KarmaConfigOptions } from './createKarmaConfig' export { createWebpackConfig } from './createWebpackConfig' diff --git a/packages/cli-tools/src/command.ts b/packages/cli-tools/src/command.ts index 30f49ad10f..3f34ec5ad7 100644 --- a/packages/cli-tools/src/command.ts +++ b/packages/cli-tools/src/command.ts @@ -53,6 +53,8 @@ export const createClientCommand = ( await client.destroy() } } + // Exit cleanly after command completes - worker threads may keep event loop alive + process.exit(0) } catch (e: any) { console.error(e) process.exit(1) diff --git a/packages/cli-tools/src/resend.ts b/packages/cli-tools/src/resend.ts index c05cd62d62..09bab29cd0 100644 --- a/packages/cli-tools/src/resend.ts +++ b/packages/cli-tools/src/resend.ts @@ -19,16 +19,19 @@ export const resend = async ( subscribe: boolean ): Promise => { try { - const handler = (message: any) => { - console.info(JSON.stringify(message)) - } if (subscribe) { + const handler = (message: any) => { + console.info(JSON.stringify(message)) + } await client.subscribe({ stream: streamId, resend: resendOpts }, handler) } else { - await client.resend(streamId, resendOpts, handler) + const messageStream = await client.resend(streamId, resendOpts) + for await (const message of messageStream) { + console.info(JSON.stringify(message.content)) + } } } catch (err) { console.error(err.message ?? err) diff --git a/packages/sdk/createKarmaConfig.ts b/packages/sdk/createKarmaConfig.ts index 8c0769f11e..2543c4e4fc 100644 --- a/packages/sdk/createKarmaConfig.ts +++ b/packages/sdk/createKarmaConfig.ts @@ -15,6 +15,7 @@ export function createKarmaConfig(testPaths: string[]): ReturnType/src/_jest/createSignatureValidationWorker.ts", "^@/(.*)$": "/src/_nodejs/$1", }, transform: { diff --git a/packages/sdk/package.json b/packages/sdk/package.json index 0b0e76c359..36149596af 100644 --- a/packages/sdk/package.json +++ b/packages/sdk/package.json @@ -31,6 +31,7 @@ "dist/exports-browser.*", "dist/exports-umd.*", "dist/encryption/migrations", + "dist/workers", "!*.tsbuildinfo", "LICENSE", "README.md", @@ -94,6 +95,7 @@ "@streamr/proto-rpc": "103.2.2", "@streamr/trackerless-network": "103.2.2", "@streamr/utils": "103.2.2", + "comlink": "^4.4.2", "env-paths": "^2.2.1", "ethers": "^6.13.0", "eventemitter3": "^5.0.0", @@ -112,6 +114,7 @@ "ts-toolbelt": "^9.6.0", "tsyringe": "^4.10.0", "uuid": "^11.1.0", + "web-worker": "^1.5.0", "zod": "^4.1.13" }, "optionalDependencies": { diff --git a/packages/sdk/rollup.config.mts b/packages/sdk/rollup.config.mts index 183eb3fca3..db9527580a 100644 --- a/packages/sdk/rollup.config.mts +++ b/packages/sdk/rollup.config.mts @@ -29,6 +29,8 @@ const browserAliases: Alias[] = [ ] export default defineConfig([ + workerNodejs(), + workerBrowser(), nodejs(), nodejsTypes(), browser(), @@ -204,3 +206,58 @@ function umdMinified(): RollupOptions { onwarn, } } + +/** + * Worker bundle for Node.js - ESM format for use with web-worker {type: 'module'} + */ +function workerNodejs(): RollupOptions { + return { + input: './dist/nodejs/src/signature/SignatureValidationWorker.js', + context: 'globalThis', + output: { + format: 'es', + file: './dist/workers/SignatureValidationWorker.node.mjs', + sourcemap: true, + }, + plugins: [ + json(), + alias({ + entries: nodejsAliases, + }), + nodeResolve({ + preferBuiltins: true, + }), + cjs(), + ], + external: [/node_modules/, /@streamr\//], + onwarn, + } +} + +/** + * Worker bundle for browser - ESM format for use with web-worker {type: 'module'} + */ +function workerBrowser(): RollupOptions { + return { + input: './dist/browser/src/signature/SignatureValidationWorker.js', + context: 'self', + output: { + format: 'es', + file: './dist/workers/SignatureValidationWorker.browser.mjs', + sourcemap: true, + }, + plugins: [ + json(), + alias({ + entries: browserAliases, + }), + nodeResolve({ + browser: true, + preferBuiltins: false, + }), + cjs(), + ], + external: [], + onwarn, + } +} diff --git a/packages/sdk/src/_browser/createSignatureValidationWorker.ts b/packages/sdk/src/_browser/createSignatureValidationWorker.ts new file mode 100644 index 0000000000..73388eefb3 --- /dev/null +++ b/packages/sdk/src/_browser/createSignatureValidationWorker.ts @@ -0,0 +1,11 @@ +/** + * Browser-specific signature validation worker factory. + */ +import Worker from 'web-worker' + +export function createSignatureValidationWorker(): InstanceType { + return new Worker( + new URL('./workers/SignatureValidationWorker.browser.mjs', import.meta.url), + { type: 'module' } + ) +} diff --git a/packages/sdk/src/_jest/createSignatureValidationWorker.ts b/packages/sdk/src/_jest/createSignatureValidationWorker.ts new file mode 100644 index 0000000000..73eedd434f --- /dev/null +++ b/packages/sdk/src/_jest/createSignatureValidationWorker.ts @@ -0,0 +1,11 @@ +/** + * Jest-specific signature validation worker factory. + */ +import Worker from 'web-worker' + +export function createSignatureValidationWorker(): InstanceType { + return new Worker( + new URL('../../dist/workers/SignatureValidationWorker.node.mjs', import.meta.url), + { type: 'module' } + ) +} diff --git a/packages/sdk/src/_karma/createSignatureValidationWorker.ts b/packages/sdk/src/_karma/createSignatureValidationWorker.ts new file mode 100644 index 0000000000..e2e4090127 --- /dev/null +++ b/packages/sdk/src/_karma/createSignatureValidationWorker.ts @@ -0,0 +1,11 @@ +/** + * Browser-specific signature validation worker factory. + */ +import Worker from 'web-worker' + +export function createSignatureValidationWorker(): InstanceType { + return new Worker( + new URL('../../dist/workers/SignatureValidationWorker.browser.mjs', import.meta.url), + { type: 'module' } + ) +} diff --git a/packages/sdk/src/_nodejs/createSignatureValidationWorker.ts b/packages/sdk/src/_nodejs/createSignatureValidationWorker.ts new file mode 100644 index 0000000000..ad891da4f6 --- /dev/null +++ b/packages/sdk/src/_nodejs/createSignatureValidationWorker.ts @@ -0,0 +1,11 @@ +/** + * Node.js-specific signature validation worker factory. + */ +import Worker from 'web-worker' + +export function createSignatureValidationWorker(): InstanceType { + return new Worker( + new URL('./workers/SignatureValidationWorker.node.mjs', import.meta.url), + { type: 'module' } + ) +} diff --git a/packages/sdk/src/signature/SignatureValidation.ts b/packages/sdk/src/signature/SignatureValidation.ts new file mode 100644 index 0000000000..760334e0b5 --- /dev/null +++ b/packages/sdk/src/signature/SignatureValidation.ts @@ -0,0 +1,31 @@ +/** + * Unified signature validation using Web Worker. + * This offloads CPU-intensive cryptographic operations to a separate thread. + * Works in both browser and Node.js environments via platform-specific config. + */ +import { wrap, releaseProxy, type Remote } from 'comlink' +import { createSignatureValidationWorker } from '@/createSignatureValidationWorker' +import { SignatureValidationResult, toSignatureValidationData } from './signatureValidationUtils' +import type { SignatureValidationWorkerApi } from './SignatureValidationWorker' +import { StreamMessage } from '../protocol/StreamMessage' + +export class SignatureValidation { + private worker: ReturnType + private workerApi: Remote + + constructor() { + this.worker = createSignatureValidationWorker() + this.workerApi = wrap(this.worker) + } + + async validateSignature(message: StreamMessage): Promise { + // Convert class instance to plain serializable data before sending to worker + const data = toSignatureValidationData(message) + return this.workerApi.validateSignature(data) + } + + destroy(): void { + this.workerApi[releaseProxy]() + this.worker.terminate() + } +} diff --git a/packages/sdk/src/signature/SignatureValidationWorker.ts b/packages/sdk/src/signature/SignatureValidationWorker.ts new file mode 100644 index 0000000000..f55ac77e7b --- /dev/null +++ b/packages/sdk/src/signature/SignatureValidationWorker.ts @@ -0,0 +1,18 @@ +import { expose } from 'comlink' +import { + validateSignatureData, + SignatureValidationResult, + SignatureValidationData, +} from './signatureValidationUtils' + +const workerApi = { + validateSignature: async ( + data: SignatureValidationData + ): Promise => { + return validateSignatureData(data) + }, +} + +export type SignatureValidationWorkerApi = typeof workerApi + +expose(workerApi) diff --git a/packages/sdk/src/signature/SignatureValidator.ts b/packages/sdk/src/signature/SignatureValidator.ts index 0c7ba17605..87328bfe0c 100644 --- a/packages/sdk/src/signature/SignatureValidator.ts +++ b/packages/sdk/src/signature/SignatureValidator.ts @@ -1,26 +1,28 @@ -import { toEthereumAddress, toUserIdRaw, SigningUtil } from '@streamr/utils' +import { toEthereumAddress } from '@streamr/utils' import { Lifecycle, scoped } from 'tsyringe' import { ERC1271ContractFacade } from '../contracts/ERC1271ContractFacade' +import { DestroySignal } from '../DestroySignal' import { StreamMessage } from '../protocol/StreamMessage' import { StreamrClientError } from '../StreamrClientError' -import { createLegacySignaturePayload } from './createLegacySignaturePayload' import { createSignaturePayload } from './createSignaturePayload' +import { SignatureValidation } from './SignatureValidation' import { SignatureType } from '@streamr/trackerless-network' -import { IDENTITY_MAPPING } from '../identity/IdentityMapping' - -// Lookup structure SignatureType -> SigningUtil -const signingUtilBySignatureType: Record = Object.fromEntries( - IDENTITY_MAPPING.map((idMapping) => [idMapping.signatureType, SigningUtil.getInstance(idMapping.keyType)]) -) - -const evmSigner = SigningUtil.getInstance('ECDSA_SECP256K1_EVM') @scoped(Lifecycle.ContainerScoped) export class SignatureValidator { private readonly erc1271ContractFacade: ERC1271ContractFacade + private signatureValidation: SignatureValidation | undefined - constructor(erc1271ContractFacade: ERC1271ContractFacade) { + constructor( + erc1271ContractFacade: ERC1271ContractFacade, + destroySignal: DestroySignal + ) { this.erc1271ContractFacade = erc1271ContractFacade + destroySignal.onDestroy.listen(() => this.destroy()) + } + + private getSignatureValidation(): SignatureValidation { + return this.signatureValidation ??= new SignatureValidation() } /** @@ -41,36 +43,33 @@ export class SignatureValidator { } private async validate(streamMessage: StreamMessage): Promise { - const signingUtil = signingUtilBySignatureType[streamMessage.signatureType] - - // Common case - if (signingUtil) { - return signingUtil.verifySignature( - toUserIdRaw(streamMessage.getPublisherId()), - createSignaturePayload(streamMessage), - streamMessage.signature - ) - } - - // Special handling: different payload computation, same SigningUtil - if (streamMessage.signatureType === SignatureType.ECDSA_SECP256K1_LEGACY) { - return evmSigner.verifySignature( - // publisherId is hex encoded Ethereum address string - toUserIdRaw(streamMessage.getPublisherId()), - createLegacySignaturePayload(streamMessage), - streamMessage.signature - ) - } - - // Special handling: check signature with ERC-1271 contract facade if (streamMessage.signatureType === SignatureType.ERC_1271) { return this.erc1271ContractFacade.isValidSignature( - toEthereumAddress(streamMessage.getPublisherId()), + toEthereumAddress(streamMessage.messageId.publisherId), createSignaturePayload(streamMessage), streamMessage.signature ) } + const result = await this.getSignatureValidation().validateSignature(streamMessage) + switch (result.type) { + case 'valid': + return true + case 'invalid': + return false + case 'error': + throw new Error(result.message) + default: + throw new Error(`Unknown signature validation result type '${result}'`) + } + } - throw new Error(`Cannot validate message signature, unsupported signatureType: "${streamMessage.signatureType}"`) + /** + * Cleanup worker resources when the validator is no longer needed. + */ + destroy(): void { + if (this.signatureValidation) { + this.signatureValidation.destroy() + this.signatureValidation = undefined + } } } diff --git a/packages/sdk/src/signature/createLegacySignaturePayload.ts b/packages/sdk/src/signature/createLegacySignaturePayload.ts index 3aadd0828e..42c785c2fe 100644 --- a/packages/sdk/src/signature/createLegacySignaturePayload.ts +++ b/packages/sdk/src/signature/createLegacySignaturePayload.ts @@ -1,7 +1,6 @@ import { binaryToHex, binaryToUtf8 } from '@streamr/utils' import { EncryptedGroupKey, EncryptionType } from '@streamr/trackerless-network' -import { MessageID } from '../protocol/MessageID' -import { MessageRef } from '../protocol/MessageRef' +import { MessageIdLike, MessageRefLike } from './createSignaturePayload' const serializeGroupKey = ({ id, data }: EncryptedGroupKey): string => { return JSON.stringify([id, binaryToHex(data)]) @@ -11,10 +10,10 @@ const serializeGroupKey = ({ id, data }: EncryptedGroupKey): string => { * Only to be used for LEGACY_SECP256K1 signature type. */ export const createLegacySignaturePayload = (opts: { - messageId: MessageID + messageId: MessageIdLike content: Uint8Array encryptionType: EncryptionType - prevMsgRef?: MessageRef + prevMsgRef?: MessageRefLike newGroupKey?: EncryptedGroupKey }): Uint8Array => { const prev = ((opts.prevMsgRef !== undefined) ? `${opts.prevMsgRef.timestamp}${opts.prevMsgRef.sequenceNumber}` : '') diff --git a/packages/sdk/src/signature/createSignaturePayload.ts b/packages/sdk/src/signature/createSignaturePayload.ts index 9cfadf7d32..0162139680 100644 --- a/packages/sdk/src/signature/createSignaturePayload.ts +++ b/packages/sdk/src/signature/createSignaturePayload.ts @@ -3,16 +3,34 @@ import { GroupKeyRequest as NewGroupKeyRequest, GroupKeyResponse as NewGroupKeyResponse } from '@streamr/trackerless-network' -import { utf8ToBinary } from '@streamr/utils' -import { MessageID } from '../protocol/MessageID' -import { MessageRef } from '../protocol/MessageRef' +import { StreamID, UserID, utf8ToBinary } from '@streamr/utils' import { StreamMessageType } from '../protocol/StreamMessage' +/** + * Plain data for message ID - accepts class instances or plain objects with the same properties. + */ +export interface MessageIdLike { + streamId: StreamID + streamPartition: number + timestamp: number + sequenceNumber: number + publisherId: UserID + msgChainId: string +} + +/** + * Plain data for message reference - accepts class instances or plain objects with the same properties. + */ +export interface MessageRefLike { + timestamp: number + sequenceNumber: number +} + export const createSignaturePayload = (opts: { - messageId: MessageID + messageId: MessageIdLike content: Uint8Array messageType: StreamMessageType - prevMsgRef?: MessageRef + prevMsgRef?: MessageRefLike newGroupKey?: EncryptedGroupKey }): Uint8Array | never => { const header = Buffer.concat([ diff --git a/packages/sdk/src/signature/signatureValidationUtils.ts b/packages/sdk/src/signature/signatureValidationUtils.ts new file mode 100644 index 0000000000..6f1cd3d348 --- /dev/null +++ b/packages/sdk/src/signature/signatureValidationUtils.ts @@ -0,0 +1,112 @@ +/** + * Core signature validation logic - shared between worker and main thread implementations. + * This file contains pure cryptographic validation functions without any network dependencies. + */ +import { SigningUtil, toUserIdRaw } from '@streamr/utils' +import { EncryptedGroupKey, EncryptionType, SignatureType } from '@streamr/trackerless-network' +import { IDENTITY_MAPPING } from '../identity/IdentityMapping' +import { createSignaturePayload, MessageIdLike, MessageRefLike } from './createSignaturePayload' +import { createLegacySignaturePayload } from './createLegacySignaturePayload' +import { StreamMessage, StreamMessageType } from '../protocol/StreamMessage' + +// Lookup structure SignatureType -> SigningUtil +const signingUtilBySignatureType: Record = Object.fromEntries( + IDENTITY_MAPPING.map((idMapping) => [idMapping.signatureType, SigningUtil.getInstance(idMapping.keyType)]) +) + +const evmSigner = SigningUtil.getInstance('ECDSA_SECP256K1_EVM') + +/** + * Result of signature validation + */ +export type SignatureValidationResult = + | { type: 'valid' } + | { type: 'invalid' } + | { type: 'error', message: string } + +/** + * Plain data type for signature validation that can be serialized to a worker. + * This contains only primitive values and simple objects (no class instances). + */ +export interface SignatureValidationData { + messageId: MessageIdLike + prevMsgRef?: MessageRefLike + messageType: StreamMessageType + content: Uint8Array + signature: Uint8Array + signatureType: SignatureType + encryptionType: EncryptionType + newGroupKey?: EncryptedGroupKey +} + +/** + * Extract plain serializable data from a StreamMessage for worker communication. + */ +export function toSignatureValidationData(message: StreamMessage): SignatureValidationData { + return { + messageId: { + streamId: message.messageId.streamId, + streamPartition: message.messageId.streamPartition, + timestamp: message.messageId.timestamp, + sequenceNumber: message.messageId.sequenceNumber, + publisherId: message.messageId.publisherId, + msgChainId: message.messageId.msgChainId, + }, + prevMsgRef: message.prevMsgRef ? { + timestamp: message.prevMsgRef.timestamp, + sequenceNumber: message.prevMsgRef.sequenceNumber, + } : undefined, + messageType: message.messageType, + content: message.content, + signature: message.signature, + signatureType: message.signatureType, + encryptionType: message.encryptionType, + newGroupKey: message.newGroupKey, + } +} + +/** + * Validate signature using extracted data. + * This is the core validation logic that can be run in a worker. + */ +export async function validateSignatureData(data: SignatureValidationData): Promise { + try { + const signingUtil = signingUtilBySignatureType[data.signatureType] + // Common case: standard signature types + if (signingUtil) { + const payload = createSignaturePayload({ + messageId: data.messageId, + content: data.content, + messageType: data.messageType, + prevMsgRef: data.prevMsgRef, + newGroupKey: data.newGroupKey, + }) + const isValid = await signingUtil.verifySignature( + toUserIdRaw(data.messageId.publisherId), + payload, + data.signature + ) + return isValid ? { type: 'valid' } : { type: 'invalid' } + } + // Special handling: legacy signature type + if (data.signatureType === SignatureType.ECDSA_SECP256K1_LEGACY) { + const payload = createLegacySignaturePayload({ + messageId: data.messageId, + content: data.content, + encryptionType: data.encryptionType, + prevMsgRef: data.prevMsgRef, + newGroupKey: data.newGroupKey, + }) + const isValid = await evmSigner.verifySignature( + toUserIdRaw(data.messageId.publisherId), + payload, + data.signature + ) + return isValid ? { type: 'valid' } : { type: 'invalid' } + } + return { type: 'error', message: `Unsupported signatureType: "${data.signatureType}"` } + } catch (err) { + return { type: 'error', message: String(err) } + } +} + diff --git a/packages/sdk/test/integration/update-encryption-key.test.ts b/packages/sdk/test/integration/update-encryption-key.test.ts index 23d65e55a0..16554a6ea3 100644 --- a/packages/sdk/test/integration/update-encryption-key.test.ts +++ b/packages/sdk/test/integration/update-encryption-key.test.ts @@ -24,7 +24,7 @@ describe('update encryption key', () => { publisher = environment.createClient() subscriber = environment.createClient({ encryption: { - keyRequestTimeout: 200 + keyRequestTimeout: 1000 } }) const stream = await publisher.createStream('/path') diff --git a/packages/sdk/test/unit/MessageFactory.test.ts b/packages/sdk/test/unit/MessageFactory.test.ts index 3b2f0b93b9..03d09f31d3 100644 --- a/packages/sdk/test/unit/MessageFactory.test.ts +++ b/packages/sdk/test/unit/MessageFactory.test.ts @@ -16,6 +16,7 @@ import { StreamMessage, StreamMessageType } from './../../src/protocol/StreamMes import { EthereumKeyPairIdentity } from '../../src/identity/EthereumKeyPairIdentity' import { EncryptionType, SignatureType, ContentType } from '@streamr/trackerless-network' import type { StrictStreamrClientConfig } from '../../src/ConfigTypes' +import { DestroySignal } from '../../src/DestroySignal' const CONTENT = { foo: 'bar' } const TIMESTAMP = Date.parse('2001-02-03T04:05:06Z') @@ -58,7 +59,7 @@ describe('MessageFactory', () => { isStreamPublisher: true }), groupKeyQueue: await createGroupKeyQueue(identity, GROUP_KEY), - signatureValidator: new SignatureValidator(opts?.erc1271ContractFacade ?? mock()), + signatureValidator: new SignatureValidator(opts?.erc1271ContractFacade ?? mock(), new DestroySignal()), messageSigner: new MessageSigner(identity), config: { validation: { diff --git a/packages/sdk/test/unit/SignatureValidator.test.ts b/packages/sdk/test/unit/SignatureValidator.test.ts index d08cd1da29..5a995dba35 100644 --- a/packages/sdk/test/unit/SignatureValidator.test.ts +++ b/packages/sdk/test/unit/SignatureValidator.test.ts @@ -9,6 +9,7 @@ import { StreamrClientError } from './../../src/StreamrClientError' import { MessageID } from './../../src/protocol/MessageID' import { StreamMessage, StreamMessageType } from './../../src/protocol/StreamMessage' import { ContentType, EncryptionType, SignatureType } from '@streamr/trackerless-network' +import { DestroySignal } from '../../src/DestroySignal' describe('SignatureValidator', () => { let erc1271ContractFacade: MockProxy @@ -16,7 +17,7 @@ describe('SignatureValidator', () => { beforeEach(() => { erc1271ContractFacade = mock() - signatureValidator = new SignatureValidator(erc1271ContractFacade) + signatureValidator = new SignatureValidator(erc1271ContractFacade, new DestroySignal()) }) describe('SECP256K1', () => { diff --git a/packages/sdk/test/unit/messagePipeline.test.ts b/packages/sdk/test/unit/messagePipeline.test.ts index 84370e858f..c267813b6f 100644 --- a/packages/sdk/test/unit/messagePipeline.test.ts +++ b/packages/sdk/test/unit/messagePipeline.test.ts @@ -86,7 +86,7 @@ describe('messagePipeline', () => { getStorageNodes: undefined as any, resends: undefined as any, streamRegistry: streamRegistry as any, - signatureValidator: new SignatureValidator(mock()), + signatureValidator: new SignatureValidator(mock(), destroySignal), groupKeyManager: new GroupKeyManager( mock(), groupKeyStore, diff --git a/packages/sdk/test/unit/validateStreamMessage.test.ts b/packages/sdk/test/unit/validateStreamMessage.test.ts index 314583c4bf..4f76a68044 100644 --- a/packages/sdk/test/unit/validateStreamMessage.test.ts +++ b/packages/sdk/test/unit/validateStreamMessage.test.ts @@ -10,6 +10,7 @@ import { validateStreamMessage } from '../../src/utils/validateStreamMessage' import { createMockMessage } from '../test-utils/utils' import { StreamMessage } from './../../src/protocol/StreamMessage' import type { StrictStreamrClientConfig } from '../../src/ConfigTypes' +import { DestroySignal } from '../../src/DestroySignal' const PARTITION_COUNT = 3 @@ -45,7 +46,7 @@ describe('Validator', () => { await validateStreamMessage( msg, streamRegistry as any, - new SignatureValidator(mock()), + new SignatureValidator(mock(), new DestroySignal()), { validation: { permissions: true, diff --git a/packages/sdk/test/unit/validateStreamMessage2.test.ts b/packages/sdk/test/unit/validateStreamMessage2.test.ts index e120307cd3..9010e93cba 100644 --- a/packages/sdk/test/unit/validateStreamMessage2.test.ts +++ b/packages/sdk/test/unit/validateStreamMessage2.test.ts @@ -12,6 +12,7 @@ import { MessageID } from './../../src/protocol/MessageID' import { MessageRef } from './../../src/protocol/MessageRef' import { StreamMessage, StreamMessageType } from './../../src/protocol/StreamMessage' import type { StrictStreamrClientConfig } from '../../src/ConfigTypes' +import { DestroySignal } from '../../src/DestroySignal' const groupKeyRequestToStreamMessage = async ( groupKeyRequest: GroupKeyRequest, @@ -69,7 +70,7 @@ describe('Validator2', () => { isStreamPublisher: (streamId: string, userId: UserID) => isPublisher(userId, streamId), isStreamSubscriber: (streamId: string, userId: UserID) => isSubscriber(userId, streamId) } as any, - new SignatureValidator(mock()), + new SignatureValidator(mock(), new DestroySignal()), { validation: { permissions: true,