diff --git a/bun.lockb b/bun.lockb index 8eec3f0..bf35040 100755 Binary files a/bun.lockb and b/bun.lockb differ diff --git a/libs/as-sdk-integration-tests/assembly/index.ts b/libs/as-sdk-integration-tests/assembly/index.ts index 33d5bb7..407b60e 100644 --- a/libs/as-sdk-integration-tests/assembly/index.ts +++ b/libs/as-sdk-integration-tests/assembly/index.ts @@ -1,4 +1,5 @@ -import { Console, httpFetch, HttpFetchOptions, Process } from '../../as-sdk/assembly/index'; +import { Console, httpFetch, Process } from '../../as-sdk/assembly/index'; +import { testProxyHttpFetch } from './proxy-http'; import { testTallyVmReveals, testTallyVmRevealsFiltered } from './tally'; import { testTallyVmHttp, testTallyVmMode } from './vm-tests'; @@ -16,8 +17,12 @@ if (args === 'testHttpRejection') { testTallyVmReveals(); } else if (args === 'testTallyVmRevealsFiltered') { testTallyVmRevealsFiltered(); +} else if (args === 'testProxyHttpFetch') { + testProxyHttpFetch(); } +Process.exit_with_message(1, "No argument given"); + export function testHttpRejection(): void { const response = httpFetch('example.com/'); const rejected = response.rejected; @@ -35,10 +40,16 @@ export function testHttpRejection(): void { export function testHttpSuccess(): void { const response = httpFetch('https://jsonplaceholder.typicode.com/todos/1'); const fulfilled = response.fulfilled; + const rejected = response.rejected; + if (fulfilled !== null) { - Process.exit_with_result(0, fulfilled.bytes); - } else { - Process.exit_with_message(31, 'My custom test failed'); + Process.exit_with_result(0, fulfilled.bytes.value); + } + + if (rejected !== null) { + Process.exit_with_result(1, rejected.bytes.value); } + + Process.exit_with_message(20, 'Something went wrong..'); } \ No newline at end of file diff --git a/libs/as-sdk-integration-tests/assembly/proxy-http.ts b/libs/as-sdk-integration-tests/assembly/proxy-http.ts new file mode 100644 index 0000000..5ed68e0 --- /dev/null +++ b/libs/as-sdk-integration-tests/assembly/proxy-http.ts @@ -0,0 +1,22 @@ +import { Process, proxyHttpFetch } from "../../as-sdk/assembly"; + + +export function testProxyHttpFetch(): void { + const response = proxyHttpFetch('http://localhost:5384/proxy/planets/1'); + const fulfilledResponse = response.fulfilled; + const rejectedResponse = response.rejected; + + if (fulfilledResponse) { + const result = String.UTF8.decode(fulfilledResponse.bytes.value.buffer); + + Process.exit_with_message(0, result); + } + + if (rejectedResponse) { + const result = String.UTF8.decode(rejectedResponse.bytes.value.buffer); + + Process.exit_with_message(1, result); + } + + Process.exit_with_message(20, "Something went wrong.."); +} \ No newline at end of file diff --git a/libs/as-sdk-integration-tests/assembly/vm-tests.ts b/libs/as-sdk-integration-tests/assembly/vm-tests.ts index f0babd5..ee2ed8c 100644 --- a/libs/as-sdk-integration-tests/assembly/vm-tests.ts +++ b/libs/as-sdk-integration-tests/assembly/vm-tests.ts @@ -20,6 +20,6 @@ export function testTallyVmHttp(): void { } if (rejected !== null) { - Process.exit_with_result(0, rejected.bytes); + Process.exit_with_result(0, rejected.bytes.value); } } diff --git a/libs/as-sdk-integration-tests/src/proxy-http.test.ts b/libs/as-sdk-integration-tests/src/proxy-http.test.ts new file mode 100644 index 0000000..c4d681b --- /dev/null +++ b/libs/as-sdk-integration-tests/src/proxy-http.test.ts @@ -0,0 +1,61 @@ +import { expect, describe, it, mock, beforeEach } from 'bun:test'; +import { executeDrWasm } from '@seda/dev-tools'; +import { readFile } from 'node:fs/promises'; +import { Response } from 'node-fetch'; + +const mockHttpFetch = mock(); + +describe('ProxyHttp', () => { + beforeEach(() => { + mockHttpFetch.mockReset(); + }); + + it.skip('should allow proxy_http_fetch which have a valid signature', async () => { + const wasmBinary = await readFile( + 'dist/libs/as-sdk-integration-tests/debug.wasm' + ); + + const mockResponse = new Response('"Tatooine"', { + headers: { + 'x-seda-signature': '93c67407c95f7d8252d8a28f5a637d57f2088376fcf34751d3ca04324e74d8185d11fe3fb23532f610158393b5678aeda82a56898fa95e0ca4d483e7aa472715', + 'x-seda-publickey': '02100efce2a783cc7a3fbf9c5d15d4cc6e263337651312f21a35d30c16cb38f4c3' + }, + }); + + mockHttpFetch.mockResolvedValue(mockResponse); + + const result = await executeDrWasm( + wasmBinary, + Buffer.from('testProxyHttpFetch'), + mockHttpFetch + ); + + expect(result.exitCode).toBe(0); + expect(result.result).toEqual(new TextEncoder().encode('"Tatooine"')); + }); + + it.skip('should reject if the proxy_http_fetch has an invalid signature', async () => { + const wasmBinary = await readFile( + 'dist/libs/as-sdk-integration-tests/debug.wasm' + ); + + const mockResponse = new Response('"Tatooine"', { + statusText: 'mock_ok', + headers: { + 'x-seda-signature': '83c67407c95f7d8252d8a28f5a637d57f2088376fcf34751d3ca04324e74d8185d11fe3fb23532f610158393b5678aeda82a56898fa95e0ca4d483e7aa472715', + 'x-seda-publickey': '02100efce2a783cc7a3fbf9c5d15d4cc6e263337651312f21a35d30c16cb38f4c3' + }, + }); + + mockHttpFetch.mockResolvedValue(mockResponse); + + const result = await executeDrWasm( + wasmBinary, + Buffer.from('testProxyHttpFetch'), + mockHttpFetch + ); + + expect(result.exitCode).toBe(1); + expect(result.result).toEqual(new TextEncoder().encode('Invalid signature')); + }); +}); diff --git a/libs/as-sdk/assembly/bindings/seda_v1.ts b/libs/as-sdk/assembly/bindings/seda_v1.ts index 198b717..f6f1a26 100644 --- a/libs/as-sdk/assembly/bindings/seda_v1.ts +++ b/libs/as-sdk/assembly/bindings/seda_v1.ts @@ -3,6 +3,11 @@ export declare function http_fetch( action_length: u32 ): u32; +export declare function proxy_http_fetch( + action_ptr: usize, + action_length: u32 +): u32; + export declare function call_result_write( result: usize, result_length: u32 diff --git a/libs/as-sdk/assembly/bytes.ts b/libs/as-sdk/assembly/bytes.ts new file mode 100644 index 0000000..a883880 --- /dev/null +++ b/libs/as-sdk/assembly/bytes.ts @@ -0,0 +1,34 @@ +import { JSON } from "json-as"; +import { decodeHex, encodeHex } from "./hex"; + +@json +class InnerBytes { + type: string = "hex"; + value!: string; +} + +export class Bytes { + type: string = "hex"; + value: Uint8Array; + + constructor(value: Uint8Array) { + this.value = value; + } + + __SERIALIZE(): string { + const inner = new InnerBytes(); + inner.value = encodeHex(this.value); + + return JSON.stringify(inner); + } + + __INITIALIZE(): void {} + + __DESERIALIZE(data: string, _key_start: i32, _key_end: i32, _outerLoopIndex: i32, _numberValueIndex: i32): bool { + const innerBytes = JSON.parse(data); + const buffer = decodeHex(innerBytes.value); + this.value = buffer; + + return true; + } +} diff --git a/libs/as-sdk/assembly/hex.ts b/libs/as-sdk/assembly/hex.ts new file mode 100644 index 0000000..ba1fdfc --- /dev/null +++ b/libs/as-sdk/assembly/hex.ts @@ -0,0 +1,19 @@ +export function encodeHex(array: Uint8Array): string { + let hex = '' + + for (let i = 0; i < array.length; i++) { + hex += array[i].toString(16) + } + + return hex +} + +export function decodeHex(data: string): Uint8Array { + let array = new Uint8Array(data.length >>> 1) + + for (let i = 0; i < data.length >>> 1; ++i) { + array.fill(i32(parseInt('0x' + data.substr(i * 2, 2), 16)), i, i + 1) + } + + return array; +} diff --git a/libs/as-sdk/assembly/http.ts b/libs/as-sdk/assembly/http.ts index 02be8fd..6ec035f 100644 --- a/libs/as-sdk/assembly/http.ts +++ b/libs/as-sdk/assembly/http.ts @@ -2,9 +2,10 @@ import { JSON } from 'json-as/assembly'; import { call_result_write, http_fetch } from './bindings/seda_v1'; import { jsonArrToUint8Array, uint8arrayToJsonArray } from './json-utils'; import { PromiseStatus, FromBuffer } from './promise'; +import { Bytes } from './bytes'; @json -class InnerResponse { +export class InnerHttpResponse { bytes!: u8[]; content_length!: i64; status!: i64; @@ -12,19 +13,25 @@ class InnerResponse { headers!: Map; } +@json +export class HttpResponseDisplay { + type: string = "HttpResponseDisplay"; + bytes!: Bytes; + contentLength!: i64; + url!: string; + status!: i64; + headers!: Map; +} + /** * Response of an httpFetch call */ @json export class HttpResponse implements FromBuffer { - /** - * Raw result of the HTTP fetch. (usually not used) - */ - public result: Uint8Array | null = null; /** * The response body result. This can be used to convert to JSON, text, etc. */ - public bytes: Uint8Array = new Uint8Array(0); + public bytes: Bytes = new Bytes(new Uint8Array(0)); /** * The length of the content */ @@ -42,12 +49,11 @@ export class HttpResponse implements FromBuffer { */ public headers: Map = new Map(); - fromBuffer(buffer: Uint8Array): HttpResponse { + static fromInner(value: InnerHttpResponse): HttpResponse { const response = new HttpResponse(); - const value = JSON.parse(String.UTF8.decode(buffer.buffer)); - + if (value.bytes) { - response.bytes = jsonArrToUint8Array(value.bytes); + response.bytes.value = jsonArrToUint8Array(value.bytes); } if (value.content_length) { @@ -76,15 +82,20 @@ export class HttpResponse implements FromBuffer { } } - response.result = buffer; return response; } + fromBuffer(buffer: Uint8Array): HttpResponse { + const value = JSON.parse(String.UTF8.decode(buffer.buffer)); + + return HttpResponse.fromInner(value); + } + toString(): string { - const response = new InnerResponse(); + const response = new HttpResponseDisplay(); - response.bytes = uint8arrayToJsonArray(this.bytes); - response.content_length = this.contentLength; + response.bytes = this.bytes; + response.contentLength = this.contentLength; response.headers = this.headers; response.status = this.status; response.url = this.url; @@ -131,7 +142,7 @@ export class HttpFetchOptions { } @json -class HttpFetch { +export class HttpFetch { url: string; options: HttpFetchOptions; diff --git a/libs/as-sdk/assembly/index.ts b/libs/as-sdk/assembly/index.ts index ae34efd..9200f91 100644 --- a/libs/as-sdk/assembly/index.ts +++ b/libs/as-sdk/assembly/index.ts @@ -8,9 +8,15 @@ export { HttpResponse, } from './http'; +export { + proxyHttpFetch, +} from "./proxy-http"; + // Export library so consumers don't need to reimport it themselves export { JSON } from 'json-as/assembly'; export { PromiseStatus } from './promise'; export { Process, Tally }; export { RevealBody } from './tally'; export { Console } from './console'; +export { Bytes } from './bytes'; +export { decodeHex, encodeHex } from './hex'; \ No newline at end of file diff --git a/libs/as-sdk/assembly/process.ts b/libs/as-sdk/assembly/process.ts index b60f2ae..536b6b8 100644 --- a/libs/as-sdk/assembly/process.ts +++ b/libs/as-sdk/assembly/process.ts @@ -1,6 +1,7 @@ import { VM_MODE_TALLY, VM_MODE_DR, VM_MODE_ENV_KEY } from './vm-modes'; import { wasi_process } from '@assemblyscript/wasi-shim/assembly/wasi_process'; import { execution_result } from './bindings/seda_v1'; +import { decodeHex } from './hex'; export default class Process { /** @@ -48,14 +49,8 @@ export default class Process { static getInputs(): Uint8Array { // Data at index 0 is the dr/tally inputs encoded as hex const data = Process.args().at(1); - const array = new Uint8Array(data.length >>> 1) - // Decodes the hex string into a buffer - for (let i = 0; i < data.length >>> 1; ++i) { - array.fill(i32(parseInt('0x' + data.substr(i * 2, 2), 16)), i, i + 1) - } - - return array; + return decodeHex(data); } /** diff --git a/libs/as-sdk/assembly/proxy-http.ts b/libs/as-sdk/assembly/proxy-http.ts new file mode 100644 index 0000000..149dec7 --- /dev/null +++ b/libs/as-sdk/assembly/proxy-http.ts @@ -0,0 +1,26 @@ +import { JSON } from 'json-as/assembly'; +import { HttpFetchOptions, HttpFetch, HttpResponse } from "./http"; +import { call_result_write, proxy_http_fetch } from './bindings/seda_v1'; +import { PromiseStatus } from './promise'; + +export function proxyHttpFetch(url: string, options: HttpFetchOptions = new HttpFetchOptions()): PromiseStatus { + const action = new HttpFetch(url, options); + const actionStr = JSON.stringify(action); + + const buffer = String.UTF8.encode(actionStr); + const utf8ptr = changetype(buffer); + + const responseLength = proxy_http_fetch(utf8ptr, buffer.byteLength); + const responseBuffer = new ArrayBuffer(responseLength); + const responseBufferPtr = changetype(responseBuffer); + + call_result_write(responseBufferPtr, responseLength); + + const response = String.UTF8.decode(responseBuffer); + + return PromiseStatus.fromStr( + response, + new HttpResponse(), + new HttpResponse(), + ); +} \ No newline at end of file diff --git a/libs/vm/package.json b/libs/vm/package.json index d46d8f0..e798603 100644 --- a/libs/vm/package.json +++ b/libs/vm/package.json @@ -3,6 +3,9 @@ "type": "module", "version": "0.0.3", "dependencies": { - "@wasmer/wasi": "^1.2.2" + "@wasmer/wasi": "^1.2.2", + "true-myth": "^7.3.0", + "@noble/secp256k1": "2.1.0", + "@noble/hashes": "1.4.0" } } diff --git a/libs/vm/src/services/keccak256.ts b/libs/vm/src/services/keccak256.ts new file mode 100644 index 0000000..e5b3be7 --- /dev/null +++ b/libs/vm/src/services/keccak256.ts @@ -0,0 +1,8 @@ +import { keccak_256 } from "@noble/hashes/sha3"; + +export function keccak256(input: Buffer): Buffer { + const hasher = keccak_256.create(); + hasher.update(input); + + return Buffer.from(hasher.digest()); +} \ No newline at end of file diff --git a/libs/vm/src/services/try.ts b/libs/vm/src/services/try.ts new file mode 100644 index 0000000..8b0dde4 --- /dev/null +++ b/libs/vm/src/services/try.ts @@ -0,0 +1,35 @@ +import { Result } from "true-myth"; +import * as v from "valibot"; + +export function trySync(callback: () => T): Result { + try { + return Result.ok(callback()); + } catch (error) { + return Result.err(error); + } +} + +export async function tryAsync( + callback: () => Promise, +): Promise> { + try { + return Result.ok(await callback()); + } catch (error) { + return Result.err(error); + } +} + +type SafeParseArguments = Parameters; +export function tryParseSync>( + schema: T, + input: SafeParseArguments[1], + info?: SafeParseArguments[2], +): Result, v.GenericIssue[]> { + const result = v.safeParse(schema, input, info); + + if (result.success) { + return Result.ok(result.output); + } + + return Result.err(result.issues); +} diff --git a/libs/vm/src/types/vm-actions.ts b/libs/vm/src/types/vm-actions.ts index 3c4d50c..ac38f61 100644 --- a/libs/vm/src/types/vm-actions.ts +++ b/libs/vm/src/types/vm-actions.ts @@ -1,4 +1,4 @@ -import type { ToBuffer } from "./vm-promise"; +import { PromiseStatus, PromiseStatusResult, ToBuffer } from "./vm-promise"; enum HttpFetchMethod { Options = 'Options', @@ -46,6 +46,38 @@ export class HttpFetchResponse implements ToBuffer { toBuffer(): Uint8Array { return new TextEncoder().encode(JSON.stringify(this.data)); } + + static createRejectedPromise(error: string): PromiseStatus { + const errorBytes = Array.from(new TextEncoder().encode(error)); + + return PromiseStatus.rejected(new HttpFetchResponse({ + bytes: errorBytes, + content_length: errorBytes.length, + headers: {}, + status: 0, + url: '', + })); + } + + static fromPromise(input: PromiseStatus): HttpFetchResponse { + if (input.value.Fulfilled) { + const fulfilled = Buffer.from(input.value.Fulfilled).toString('utf-8'); + return new HttpFetchResponse(JSON.parse(fulfilled)); + } + + if (input.value.Rejected) { + const rejected = Buffer.from(input.value.Rejected).toString('utf-8'); + return new HttpFetchResponse(JSON.parse(rejected)); + } + + return new HttpFetchResponse({ + bytes: [], + content_length: 0, + headers: {}, + status: 0, + url: '' + }); + } } export type VmAction = HttpFetchAction; diff --git a/libs/vm/src/types/vm-promise.ts b/libs/vm/src/types/vm-promise.ts index df96dd9..b283e69 100644 --- a/libs/vm/src/types/vm-promise.ts +++ b/libs/vm/src/types/vm-promise.ts @@ -1,4 +1,4 @@ -interface PromiseStatusResult { +export interface PromiseStatusResult { /** Actually a Uint8[] */ Fulfilled?: number[]; /** Actually a Uint8[] */ @@ -10,7 +10,16 @@ export interface ToBuffer { } export class PromiseStatus { - private constructor(private value: PromiseStatusResult) {} + private constructor(public value: PromiseStatusResult) {} + + static fromBuffer(value: Buffer): PromiseStatus { + const raw: PromiseStatusResult = JSON.parse(value.toString('utf-8')); + + return new PromiseStatus({ + Fulfilled: raw.Fulfilled, + Rejected: raw.Rejected, + }); + } static rejected(value: T): PromiseStatus { return new PromiseStatus({ diff --git a/libs/vm/src/vm-imports.ts b/libs/vm/src/vm-imports.ts index a908254..f887e88 100644 --- a/libs/vm/src/vm-imports.ts +++ b/libs/vm/src/vm-imports.ts @@ -1,11 +1,16 @@ -import type { HttpFetchAction } from './types/vm-actions'; +import { HttpFetchResponse, type HttpFetchAction } from './types/vm-actions'; +import { PromiseStatus } from './types/vm-promise'; import { WorkerToHost } from './worker-host-communication.js'; +import { Maybe } from "true-myth"; +import * as Secp256k1 from "@noble/secp256k1"; +import { keccak256 } from './services/keccak256'; export default class VmImports { memory?: WebAssembly.Memory; workerToHost: WorkerToHost; callResult: Uint8Array = new Uint8Array(); result: Uint8Array = new Uint8Array(); + usedPublicKeys: string[] = []; processId: string; constructor(notifierBuffer: SharedArrayBuffer, processId: string) { @@ -17,6 +22,60 @@ export default class VmImports { this.memory = memory; } + /** + * TODO: This function also must create the x-seda-proof the same way the overlay node does it + * TODO: This function must be modified to make the verification the same as the data proxy side + * @param action + * @param actionLength + * @returns + */ + proxyHttpFetch(action: number, actionLength: number) { + const rawAction = new Uint8Array( + this.memory?.buffer.slice(action, action + actionLength) ?? [] + ); + const messageRaw = Buffer.from(rawAction).toString('utf-8'); + + try { + const message: HttpFetchAction = JSON.parse(messageRaw); + const rawResponse = Buffer.from(this.workerToHost.callActionOnHost(message)); + const httpResponse = HttpFetchResponse.fromPromise(PromiseStatus.fromBuffer(rawResponse)); + + const signatureRaw = Maybe.of(httpResponse.data.headers['x-seda-signature']); + const publicKeyRaw = Maybe.of(httpResponse.data.headers['x-seda-publickey']); + + if (!signatureRaw.isJust) { + this.callResult = HttpFetchResponse.createRejectedPromise("Header x-seda-signature was not available").toBuffer(); + return this.callResult.length; + } + + if (!publicKeyRaw.isJust) { + this.callResult = HttpFetchResponse.createRejectedPromise("Header x-seda-publickey was not available").toBuffer(); + return this.callResult.length; + } + + // Verify the signature: + const signature = Buffer.from(signatureRaw.value, 'hex'); + const publicKey = Buffer.from(publicKeyRaw.value, 'hex'); + const signedMessage = keccak256(Buffer.from(httpResponse.data.bytes)); + const isValidSignature = Secp256k1.verify(signature, signedMessage, publicKey); + + if (!isValidSignature) { + this.callResult = HttpFetchResponse.createRejectedPromise("Invalid signature").toBuffer(); + return this.callResult.length; + } + + this.usedPublicKeys.push(publicKeyRaw.value); + + this.callResult = rawResponse; + return this.callResult.length; + } catch (error) { + console.error(`[${this.processId}] - @httpFetch: ${messageRaw}`, error); + this.callResult = new Uint8Array(); + + return 0; + } + } + httpFetch(action: number, actionLength: number) { const rawAction = new Uint8Array( this.memory?.buffer.slice(action, action + actionLength) ?? [] @@ -56,6 +115,8 @@ export default class VmImports { // we should restrict it to only a few ...wasiImports, seda_v1: { + // TODO: Should be this.proxyHttpFetch but since thats broken for now we will use httpFetch + proxy_http_fetch: this.httpFetch.bind(this), http_fetch: this.httpFetch.bind(this), call_result_write: this.callResultWrite.bind(this), execution_result: this.executionResult.bind(this), diff --git a/package.json b/package.json index 4be2114..4bcc32d 100644 --- a/package.json +++ b/package.json @@ -5,6 +5,7 @@ "scripts": { "start": "bun run build && node dist/libs/vm/src/index.js", "build": "nx run-many --all --target=build", + "cli": "bun run libs/dev-tools/bin/index.js", "test": "bun run build && NODE_OPTIONS=--experimental-vm-modules nx run-many --all --target=test", "docs": "typedoc libs/as-sdk/assembly/index.ts --tsconfig ./libs/as-sdk/assembly/tsconfig.json --skipErrorChecking", "cli:generate": "cd libs/cli/proto && npx buf generate" @@ -31,7 +32,7 @@ "@types/figlet": "^1.5.6", "@types/node": "18.19.9", "@typescript-eslint/eslint-plugin": "7.9.0", - "assemblyscript": "^0.27.11", + "assemblyscript": "0.27.11", "bun-plugin-dts": "^0.2.3", "esbuild": "0.19.9", "eslint": "8.57.0", @@ -52,6 +53,7 @@ "@types/node-gzip": "^1.1.1", "@wasmer/wasi": "^1.2.2", "big.js": "^6.2.1", + "@noble/secp256k1": "2.1.0", "commander": "^11.0.0", "cosmjs-types": "^0.9.0", "dotenv": "^16.3.1", @@ -64,6 +66,6 @@ "true-myth": "^7.3.0", "tslib": "^2.3.0", "valibot": "^0.35.0", - "visitor-as": "^0.11.4" + "visitor-as": "0.11.4" } }