phase 5: wasm core, GalaxyClient skeleton, Connect-Web stubs

Compile `ui/core` to WebAssembly via TinyGo (903 KB) and expose four
canonical-bytes / signature-verification functions on
`globalThis.galaxyCore` from `ui/wasm/main.go`. The TypeScript-side
`Core` interface plus a `WasmCore` adapter (browser + JSDOM loader)
bridge those into a typed shape, and a `GalaxyClient` skeleton wires
`Core.signRequest` → injected `Signer` → typed Connect client →
`Core.verifyPayloadHash` / `verifyResponse`.

Wire `ui/buf.gen.yaml` against the local
`@bufbuild/protoc-gen-es` v2 binary (devDependency) so the codegen
step does not depend on the buf.build BSR. Vitest covers the bridge
end-to-end: per-method WasmCore tests under JSDOM, byte-for-byte
canon parity against the gateway fixtures committed in Phase 3, and
a `GalaxyClient` orchestration test using
`createRouterTransport`. The committed `core.wasm` snapshot tracks
TinyGo output so contributors run `make wasm` only when `ui/core/`
changes; CI consumes the snapshot directly.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
Ilia Denisov
2026-05-07 12:58:37 +02:00
parent cd61868881
commit fbc0260720
25 changed files with 7284 additions and 36 deletions
+4
View File
@@ -12,6 +12,10 @@
"test:e2e": "playwright test"
},
"devDependencies": {
"@bufbuild/protobuf": "^2.12.0",
"@bufbuild/protoc-gen-es": "^2.12.0",
"@connectrpc/connect": "^2.1.1",
"@connectrpc/connect-web": "^2.1.1",
"@playwright/test": "^1.59.1",
"@sveltejs/adapter-static": "^3.0.0",
"@sveltejs/kit": "^2.59.0",
+20
View File
@@ -0,0 +1,20 @@
// `createEdgeGatewayClient` builds a typed Connect-Web client for the
// gateway's authenticated edge surface. It speaks the Connect protocol
// over HTTP/1.1 (or HTTP/2 if the host upgrades the connection) — the
// gateway listener built in Phase 4 natively serves Connect, gRPC, and
// gRPC-Web on the same h2c port.
//
// The factory is intentionally thin: callers provide the gateway base
// URL (e.g. https://api.galaxy.test), and receive a typed
// `EdgeGatewayClient`. Authentication, signing, and response
// verification live one layer up, in `GalaxyClient`.
import { createClient, type Client } from "@connectrpc/connect";
import { createConnectTransport } from "@connectrpc/connect-web";
import { EdgeGateway } from "../proto/galaxy/gateway/v1/edge_gateway_pb";
export type EdgeGatewayClient = Client<typeof EdgeGateway>;
export function createEdgeGatewayClient(baseUrl: string): EdgeGatewayClient {
return createClient(EdgeGateway, createConnectTransport({ baseUrl }));
}
+149
View File
@@ -0,0 +1,149 @@
// `GalaxyClient` orchestrates one authenticated unary round-trip:
// build canonical request bytes via `Core.signRequest`, sign them
// through an injected `Signer`, post the envelope through the typed
// Connect client, then verify the response signature and payload hash
// before returning the decoded payload.
//
// Phase 5 ships the orchestration only — Phase 6 plugs the WebCrypto
// `Signer` and `KeyStore`, and Phase 7 wires the auth flow that
// actually creates the device session. Tests pass an exportable
// fixture key wrapped as a `Signer` so the flow can be exercised
// end-to-end without a live gateway.
import { create } from "@bufbuild/protobuf";
import type { Core } from "../platform/core/index";
import {
ExecuteCommandRequestSchema,
type ExecuteCommandResponse,
} from "../proto/galaxy/gateway/v1/edge_gateway_pb";
import type { EdgeGatewayClient } from "./connect";
/**
* Signer produces a raw 64-byte Ed25519 signature over canonicalBytes.
* Phase 6 backs this with WebCrypto + a non-exportable IDB key handle.
* Phase 5 tests pass a function that closes over `crypto.sign(...)`
* with an exportable fixture key.
*/
export type Signer = (canonicalBytes: Uint8Array) => Promise<Uint8Array>;
/**
* Sha256 computes the raw 32-byte SHA-256 digest of payload. The
* GalaxyClient receives this as a dependency so JSDOM tests can pass
* Node's `crypto` and the browser path can use `crypto.subtle`.
*/
export type Sha256 = (payload: Uint8Array) => Promise<Uint8Array>;
export interface GalaxyClientOptions {
core: Core;
edge: EdgeGatewayClient;
signer: Signer;
sha256: Sha256;
deviceSessionId: string;
gatewayResponsePublicKey: Uint8Array;
clock?: () => bigint;
requestIdFactory?: () => string;
}
const PROTOCOL_VERSION = "v1";
export class GalaxyClient {
private readonly core: Core;
private readonly edge: EdgeGatewayClient;
private readonly signer: Signer;
private readonly sha256: Sha256;
private readonly deviceSessionId: string;
private readonly gatewayResponsePublicKey: Uint8Array;
private readonly clock: () => bigint;
private readonly requestIdFactory: () => string;
constructor(options: GalaxyClientOptions) {
this.core = options.core;
this.edge = options.edge;
this.signer = options.signer;
this.sha256 = options.sha256;
this.deviceSessionId = options.deviceSessionId;
this.gatewayResponsePublicKey = options.gatewayResponsePublicKey;
this.clock = options.clock ?? defaultClock;
this.requestIdFactory =
options.requestIdFactory ?? defaultRequestIdFactory;
}
async executeCommand(
messageType: string,
payload: Uint8Array,
traceId = "",
): Promise<Uint8Array> {
const requestId = this.requestIdFactory();
const timestampMs = this.clock();
const payloadHash = await this.sha256(payload);
const canonicalBytes = this.core.signRequest({
protocolVersion: PROTOCOL_VERSION,
deviceSessionId: this.deviceSessionId,
messageType,
timestampMs,
requestId,
payloadHash,
});
const signature = await this.signer(canonicalBytes);
const response = await this.edge.executeCommand(
create(ExecuteCommandRequestSchema, {
protocolVersion: PROTOCOL_VERSION,
deviceSessionId: this.deviceSessionId,
messageType,
timestampMs,
requestId,
payloadBytes: payload,
payloadHash,
signature,
traceId,
}),
);
this.verifyResponse(response, requestId);
return response.payloadBytes;
}
private verifyResponse(
response: ExecuteCommandResponse,
requestId: string,
): void {
if (response.requestId !== requestId) {
throw new Error(
`galaxy-client: response request_id ${response.requestId} does not match ${requestId}`,
);
}
if (
!this.core.verifyPayloadHash(
response.payloadBytes,
response.payloadHash,
)
) {
throw new Error("galaxy-client: response payload_hash mismatch");
}
const ok = this.core.verifyResponse(
this.gatewayResponsePublicKey,
response.signature,
{
protocolVersion: response.protocolVersion,
requestId: response.requestId,
timestampMs: response.timestampMs,
resultCode: response.resultCode,
payloadHash: response.payloadHash,
},
);
if (!ok) {
throw new Error("galaxy-client: invalid response signature");
}
}
}
function defaultClock(): bigint {
return BigInt(Date.now());
}
function defaultRequestIdFactory(): string {
return crypto.randomUUID();
}
+85
View File
@@ -0,0 +1,85 @@
// `Core` is the typed TypeScript view of the Galaxy compute boundary
// (`ui/core` Go module compiled to WASM/native). It only carries
// canonical-bytes serialization and signature verification — no
// network I/O, no key storage, no signing private keys. Real Ed25519
// signing happens in a platform-specific `Signer` (Phase 6 introduces
// WebCrypto for the web target).
//
// All byte fields are `Uint8Array`. Timestamps are `bigint` to keep
// nanosecond-resolution-friendly headroom and to mirror the v2
// Protobuf-ES `int64 → bigint` mapping used by the generated stubs.
export interface RequestSigningFields {
protocolVersion: string;
deviceSessionId: string;
messageType: string;
timestampMs: bigint;
requestId: string;
payloadHash: Uint8Array;
}
export interface ResponseSigningFields {
protocolVersion: string;
requestId: string;
timestampMs: bigint;
resultCode: string;
payloadHash: Uint8Array;
}
export interface EventSigningFields {
eventType: string;
eventId: string;
timestampMs: bigint;
requestId: string;
traceId: string;
payloadHash: Uint8Array;
}
export interface Core {
/**
* signRequest returns the canonical signing input bytes for a v1
* request envelope. Callers feed the result into a platform `Signer`
* (e.g. WebCrypto) to produce the actual Ed25519 signature.
*/
signRequest(fields: RequestSigningFields): Uint8Array;
/**
* verifyResponse authenticates a server response signature against
* the canonical v1 response signing input.
*/
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: ResponseSigningFields,
): boolean;
/**
* verifyEvent authenticates a push-event signature against the
* canonical v1 event signing input.
*/
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: EventSigningFields,
): boolean;
/**
* verifyPayloadHash returns true when payloadHash is the raw 32-byte
* SHA-256 digest of payloadBytes.
*/
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
}
export type CoreLoader = () => Promise<Core>;
import { loadWasmCore } from "./wasm";
/**
* loadCore resolves the Core implementation appropriate for the current
* build target. Phase 5 ships only the WASM adapter; later phases plug
* `WailsCore` and `CapacitorCore` here behind a build-time selector.
*/
export const loadCore: CoreLoader = loadWasmCore;
+196
View File
@@ -0,0 +1,196 @@
// `WasmCore` is the browser-side `Core` implementation. It loads the
// TinyGo-built `core.wasm` module + the matching `wasm_exec.js` runtime
// shim and bridges the four exported functions on `globalThis.galaxyCore`
// to the typed `Core` interface.
//
// The same module also runs under Node/JSDOM (via Vitest) by reading
// the bundle from disk; the test loader lives in
// `tests/setup-wasm.ts`. The browser path is the production target
// served from `static/core.wasm`.
import type {
Core,
EventSigningFields,
RequestSigningFields,
ResponseSigningFields,
} from "./index";
/**
* GalaxyCoreBridge is the shape Go installs on `globalThis.galaxyCore`.
* It is purely an internal contract between `ui/wasm/main.go` and this
* adapter; consumers see only the typed `Core` interface.
*/
interface GalaxyCoreBridge {
signRequest(fields: BridgeRequestFields): Uint8Array;
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: BridgeResponseFields,
): boolean;
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: BridgeEventFields,
): boolean;
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean;
}
interface BridgeRequestFields {
protocolVersion: string;
deviceSessionId: string;
messageType: string;
timestampMs: number;
requestId: string;
payloadHash: Uint8Array;
}
interface BridgeResponseFields {
protocolVersion: string;
requestId: string;
timestampMs: number;
resultCode: string;
payloadHash: Uint8Array;
}
interface BridgeEventFields {
eventType: string;
eventId: string;
timestampMs: number;
requestId: string;
traceId: string;
payloadHash: Uint8Array;
}
declare global {
// eslint-disable-next-line no-var
var galaxyCore: GalaxyCoreBridge | undefined;
// eslint-disable-next-line no-var
var Go: { new (): GoRuntime } | undefined;
}
interface GoRuntime {
importObject: WebAssembly.Imports;
run(instance: WebAssembly.Instance): Promise<void>;
}
/**
* loadWasmCore boots the TinyGo `core.wasm` module in the browser and
* returns a `Core` whose methods bridge into the Go-exported functions.
* The module is started exactly once per page; subsequent calls return
* the cached instance. The browser path expects `wasm_exec.js` to be
* served alongside `core.wasm` under SvelteKit's `static/` root.
*/
let cached: Promise<Core> | undefined;
export function loadWasmCore(): Promise<Core> {
if (!cached) {
cached = bootBrowserWasm();
}
return cached;
}
async function bootBrowserWasm(): Promise<Core> {
if (typeof window === "undefined") {
throw new Error(
"loadWasmCore: no `window` global; load via tests/setup-wasm.ts under JSDOM",
);
}
await ensureGoRuntimeLoaded();
const Go = globalThis.Go;
if (!Go) {
throw new Error("loadWasmCore: Go runtime missing after wasm_exec.js load");
}
const go = new Go();
const response = await fetch("/core.wasm");
const bytes = await response.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes, go.importObject);
void go.run(instance);
return adaptBridge(requireBridge());
}
async function ensureGoRuntimeLoaded(): Promise<void> {
if (typeof globalThis.Go !== "undefined") {
return;
}
await new Promise<void>((resolve, reject) => {
const script = document.createElement("script");
script.src = "/wasm_exec.js";
script.onload = () => resolve();
script.onerror = () => reject(new Error("failed to load /wasm_exec.js"));
document.head.appendChild(script);
});
}
/**
* adaptBridge wraps the raw Go-installed bridge with the `Core` shape,
* normalising `bigint` ↔ JS Number conversion at the timestamp boundary.
* The bridge is also exported for the JSDOM test loader, which boots
* the WASM module differently (see `tests/setup-wasm.ts`).
*/
export function adaptBridge(bridge: GalaxyCoreBridge): Core {
return {
signRequest(fields: RequestSigningFields): Uint8Array {
return bridge.signRequest({
protocolVersion: fields.protocolVersion,
deviceSessionId: fields.deviceSessionId,
messageType: fields.messageType,
timestampMs: bigintToNumber(fields.timestampMs),
requestId: fields.requestId,
payloadHash: fields.payloadHash,
});
},
verifyResponse(
publicKey: Uint8Array,
signature: Uint8Array,
fields: ResponseSigningFields,
): boolean {
return bridge.verifyResponse(publicKey, signature, {
protocolVersion: fields.protocolVersion,
requestId: fields.requestId,
timestampMs: bigintToNumber(fields.timestampMs),
resultCode: fields.resultCode,
payloadHash: fields.payloadHash,
});
},
verifyEvent(
publicKey: Uint8Array,
signature: Uint8Array,
fields: EventSigningFields,
): boolean {
return bridge.verifyEvent(publicKey, signature, {
eventType: fields.eventType,
eventId: fields.eventId,
timestampMs: bigintToNumber(fields.timestampMs),
requestId: fields.requestId,
traceId: fields.traceId,
payloadHash: fields.payloadHash,
});
},
verifyPayloadHash(
payloadBytes: Uint8Array,
payloadHash: Uint8Array,
): boolean {
return bridge.verifyPayloadHash(payloadBytes, payloadHash);
},
};
}
export function requireBridge(): GalaxyCoreBridge {
const bridge = globalThis.galaxyCore;
if (!bridge) {
throw new Error(
"loadWasmCore: `globalThis.galaxyCore` not installed — was the WASM module instantiated?",
);
}
return bridge;
}
// Unix-millisecond timestamps fit in 53 bits well past the year 2200, so
// the bigint → number narrowing is safe for every realistic value the
// envelope contract carries.
function bigintToNumber(value: bigint): number {
return Number(value);
}
File diff suppressed because one or more lines are too long
@@ -0,0 +1,262 @@
// @generated by protoc-gen-es v2.12.0 with parameter "target=ts"
// @generated from file galaxy/gateway/v1/edge_gateway.proto (package galaxy.gateway.v1, syntax proto3)
/* eslint-disable */
import type { GenFile, GenMessage, GenService } from "@bufbuild/protobuf/codegenv2";
import { fileDesc, messageDesc, serviceDesc } from "@bufbuild/protobuf/codegenv2";
import { file_buf_validate_validate } from "../../../buf/validate/validate_pb";
import type { Message } from "@bufbuild/protobuf";
/**
* Describes the file galaxy/gateway/v1/edge_gateway.proto.
*/
export const file_galaxy_gateway_v1_edge_gateway: GenFile = /*@__PURE__*/
fileDesc("CiRnYWxheHkvZ2F0ZXdheS92MS9lZGdlX2dhdGV3YXkucHJvdG8SEWdhbGF4eS5nYXRld2F5LnYxIqYCChVFeGVjdXRlQ29tbWFuZFJlcXVlc3QSIQoQcHJvdG9jb2xfdmVyc2lvbhgBIAEoCUIHukgEcgIQARIiChFkZXZpY2Vfc2Vzc2lvbl9pZBgCIAEoCUIHukgEcgIQARIdCgxtZXNzYWdlX3R5cGUYAyABKAlCB7pIBHICEAESHQoMdGltZXN0YW1wX21zGAQgASgDQge6SAQiAiAAEhsKCnJlcXVlc3RfaWQYBSABKAlCB7pIBHICEAESHgoNcGF5bG9hZF9ieXRlcxgGIAEoDEIHukgEegIQARIdCgxwYXlsb2FkX2hhc2gYByABKAxCB7pIBHoCEAESGgoJc2lnbmF0dXJlGAggASgMQge6SAR6AhABEhAKCHRyYWNlX2lkGAkgASgJIrEBChZFeGVjdXRlQ29tbWFuZFJlc3BvbnNlEhgKEHByb3RvY29sX3ZlcnNpb24YASABKAkSEgoKcmVxdWVzdF9pZBgCIAEoCRIUCgx0aW1lc3RhbXBfbXMYAyABKAMSEwoLcmVzdWx0X2NvZGUYBCABKAkSFQoNcGF5bG9hZF9ieXRlcxgFIAEoDBIUCgxwYXlsb2FkX2hhc2gYBiABKAwSEQoJc2lnbmF0dXJlGAcgASgMIp4CChZTdWJzY3JpYmVFdmVudHNSZXF1ZXN0EiEKEHByb3RvY29sX3ZlcnNpb24YASABKAlCB7pIBHICEAESIgoRZGV2aWNlX3Nlc3Npb25faWQYAiABKAlCB7pIBHICEAESHQoMbWVzc2FnZV90eXBlGAMgASgJQge6SARyAhABEh0KDHRpbWVzdGFtcF9tcxgEIAEoA0IHukgEIgIgABIbCgpyZXF1ZXN0X2lkGAUgASgJQge6SARyAhABEh0KDHBheWxvYWRfaGFzaBgGIAEoDEIHukgEegIQARIaCglzaWduYXR1cmUYByABKAxCB7pIBHoCEAESFQoNcGF5bG9hZF9ieXRlcxgIIAEoDBIQCgh0cmFjZV9pZBgJIAEoCSKwAQoMR2F0ZXdheUV2ZW50EhIKCmV2ZW50X3R5cGUYASABKAkSEAoIZXZlbnRfaWQYAiABKAkSFAoMdGltZXN0YW1wX21zGAMgASgDEhUKDXBheWxvYWRfYnl0ZXMYBCABKAwSFAoMcGF5bG9hZF9oYXNoGAUgASgMEhEKCXNpZ25hdHVyZRgGIAEoDBISCgpyZXF1ZXN0X2lkGAcgASgJEhAKCHRyYWNlX2lkGAggASgJMtUBCgtFZGdlR2F0ZXdheRJlCg5FeGVjdXRlQ29tbWFuZBIoLmdhbGF4eS5nYXRld2F5LnYxLkV4ZWN1dGVDb21tYW5kUmVxdWVzdBopLmdhbGF4eS5nYXRld2F5LnYxLkV4ZWN1dGVDb21tYW5kUmVzcG9uc2USXwoPU3Vic2NyaWJlRXZlbnRzEikuZ2FsYXh5LmdhdGV3YXkudjEuU3Vic2NyaWJlRXZlbnRzUmVxdWVzdBofLmdhbGF4eS5nYXRld2F5LnYxLkdhdGV3YXlFdmVudDABQjJaMGdhbGF4eS9nYXRld2F5L3Byb3RvL2dhbGF4eS9nYXRld2F5L3YxO2dhdGV3YXl2MWIGcHJvdG8z", [file_buf_validate_validate]);
/**
* @generated from message galaxy.gateway.v1.ExecuteCommandRequest
*/
export type ExecuteCommandRequest = Message<"galaxy.gateway.v1.ExecuteCommandRequest"> & {
/**
* protocol_version identifies the request envelope version. The gateway
* accepts only the literal "v1" after required-field validation succeeds.
*
* @generated from field: string protocol_version = 1;
*/
protocolVersion: string;
/**
* @generated from field: string device_session_id = 2;
*/
deviceSessionId: string;
/**
* @generated from field: string message_type = 3;
*/
messageType: string;
/**
* @generated from field: int64 timestamp_ms = 4;
*/
timestampMs: bigint;
/**
* @generated from field: string request_id = 5;
*/
requestId: string;
/**
* @generated from field: bytes payload_bytes = 6;
*/
payloadBytes: Uint8Array;
/**
* payload_hash is the raw 32-byte SHA-256 digest of payload_bytes.
*
* @generated from field: bytes payload_hash = 7;
*/
payloadHash: Uint8Array;
/**
* @generated from field: bytes signature = 8;
*/
signature: Uint8Array;
/**
* @generated from field: string trace_id = 9;
*/
traceId: string;
};
/**
* Describes the message galaxy.gateway.v1.ExecuteCommandRequest.
* Use `create(ExecuteCommandRequestSchema)` to create a new message.
*/
export const ExecuteCommandRequestSchema: GenMessage<ExecuteCommandRequest> = /*@__PURE__*/
messageDesc(file_galaxy_gateway_v1_edge_gateway, 0);
/**
* @generated from message galaxy.gateway.v1.ExecuteCommandResponse
*/
export type ExecuteCommandResponse = Message<"galaxy.gateway.v1.ExecuteCommandResponse"> & {
/**
* @generated from field: string protocol_version = 1;
*/
protocolVersion: string;
/**
* @generated from field: string request_id = 2;
*/
requestId: string;
/**
* @generated from field: int64 timestamp_ms = 3;
*/
timestampMs: bigint;
/**
* @generated from field: string result_code = 4;
*/
resultCode: string;
/**
* @generated from field: bytes payload_bytes = 5;
*/
payloadBytes: Uint8Array;
/**
* @generated from field: bytes payload_hash = 6;
*/
payloadHash: Uint8Array;
/**
* @generated from field: bytes signature = 7;
*/
signature: Uint8Array;
};
/**
* Describes the message galaxy.gateway.v1.ExecuteCommandResponse.
* Use `create(ExecuteCommandResponseSchema)` to create a new message.
*/
export const ExecuteCommandResponseSchema: GenMessage<ExecuteCommandResponse> = /*@__PURE__*/
messageDesc(file_galaxy_gateway_v1_edge_gateway, 1);
/**
* @generated from message galaxy.gateway.v1.SubscribeEventsRequest
*/
export type SubscribeEventsRequest = Message<"galaxy.gateway.v1.SubscribeEventsRequest"> & {
/**
* protocol_version identifies the request envelope version. The gateway
* accepts only the literal "v1" after required-field validation succeeds.
*
* @generated from field: string protocol_version = 1;
*/
protocolVersion: string;
/**
* @generated from field: string device_session_id = 2;
*/
deviceSessionId: string;
/**
* @generated from field: string message_type = 3;
*/
messageType: string;
/**
* @generated from field: int64 timestamp_ms = 4;
*/
timestampMs: bigint;
/**
* @generated from field: string request_id = 5;
*/
requestId: string;
/**
* payload_hash is the raw 32-byte SHA-256 digest of payload_bytes. Empty
* payloads must use the SHA-256 digest of the empty byte slice.
*
* @generated from field: bytes payload_hash = 6;
*/
payloadHash: Uint8Array;
/**
* @generated from field: bytes signature = 7;
*/
signature: Uint8Array;
/**
* @generated from field: bytes payload_bytes = 8;
*/
payloadBytes: Uint8Array;
/**
* @generated from field: string trace_id = 9;
*/
traceId: string;
};
/**
* Describes the message galaxy.gateway.v1.SubscribeEventsRequest.
* Use `create(SubscribeEventsRequestSchema)` to create a new message.
*/
export const SubscribeEventsRequestSchema: GenMessage<SubscribeEventsRequest> = /*@__PURE__*/
messageDesc(file_galaxy_gateway_v1_edge_gateway, 2);
/**
* @generated from message galaxy.gateway.v1.GatewayEvent
*/
export type GatewayEvent = Message<"galaxy.gateway.v1.GatewayEvent"> & {
/**
* @generated from field: string event_type = 1;
*/
eventType: string;
/**
* @generated from field: string event_id = 2;
*/
eventId: string;
/**
* @generated from field: int64 timestamp_ms = 3;
*/
timestampMs: bigint;
/**
* @generated from field: bytes payload_bytes = 4;
*/
payloadBytes: Uint8Array;
/**
* @generated from field: bytes payload_hash = 5;
*/
payloadHash: Uint8Array;
/**
* @generated from field: bytes signature = 6;
*/
signature: Uint8Array;
/**
* @generated from field: string request_id = 7;
*/
requestId: string;
/**
* @generated from field: string trace_id = 8;
*/
traceId: string;
};
/**
* Describes the message galaxy.gateway.v1.GatewayEvent.
* Use `create(GatewayEventSchema)` to create a new message.
*/
export const GatewayEventSchema: GenMessage<GatewayEvent> = /*@__PURE__*/
messageDesc(file_galaxy_gateway_v1_edge_gateway, 3);
/**
* @generated from service galaxy.gateway.v1.EdgeGateway
*/
export const EdgeGateway: GenService<{
/**
* @generated from rpc galaxy.gateway.v1.EdgeGateway.ExecuteCommand
*/
executeCommand: {
methodKind: "unary";
input: typeof ExecuteCommandRequestSchema;
output: typeof ExecuteCommandResponseSchema;
},
/**
* @generated from rpc galaxy.gateway.v1.EdgeGateway.SubscribeEvents
*/
subscribeEvents: {
methodKind: "server_streaming";
input: typeof SubscribeEventsRequestSchema;
output: typeof GatewayEventSchema;
},
}> = /*@__PURE__*/
serviceDesc(file_galaxy_gateway_v1_edge_gateway, 0);
Binary file not shown.
+559
View File
@@ -0,0 +1,559 @@
// Copyright 2018 The Go Authors. All rights reserved.
// Use of this source code is governed by a BSD-style
// license that can be found in the LICENSE file.
//
// This file has been modified for use by the TinyGo compiler.
(() => {
// Map multiple JavaScript environments to a single common API,
// preferring web standards over Node.js API.
//
// Environments considered:
// - Browsers
// - Node.js
// - Electron
// - Parcel
if (typeof global !== "undefined") {
// global already exists
} else if (typeof window !== "undefined") {
window.global = window;
} else if (typeof self !== "undefined") {
self.global = self;
} else {
throw new Error("cannot export Go (neither global, window nor self is defined)");
}
if (!global.require && typeof require !== "undefined") {
global.require = require;
}
if (!global.fs && global.require) {
global.fs = require("node:fs");
}
const enosys = () => {
const err = new Error("not implemented");
err.code = "ENOSYS";
return err;
};
if (!global.fs) {
let outputBuf = "";
global.fs = {
constants: { O_WRONLY: -1, O_RDWR: -1, O_CREAT: -1, O_TRUNC: -1, O_APPEND: -1, O_EXCL: -1 }, // unused
writeSync(fd, buf) {
outputBuf += decoder.decode(buf);
const nl = outputBuf.lastIndexOf("\n");
if (nl != -1) {
console.log(outputBuf.substr(0, nl));
outputBuf = outputBuf.substr(nl + 1);
}
return buf.length;
},
write(fd, buf, offset, length, position, callback) {
if (offset !== 0 || length !== buf.length || position !== null) {
callback(enosys());
return;
}
const n = this.writeSync(fd, buf);
callback(null, n);
},
chmod(path, mode, callback) { callback(enosys()); },
chown(path, uid, gid, callback) { callback(enosys()); },
close(fd, callback) { callback(enosys()); },
fchmod(fd, mode, callback) { callback(enosys()); },
fchown(fd, uid, gid, callback) { callback(enosys()); },
fstat(fd, callback) { callback(enosys()); },
fsync(fd, callback) { callback(null); },
ftruncate(fd, length, callback) { callback(enosys()); },
lchown(path, uid, gid, callback) { callback(enosys()); },
link(path, link, callback) { callback(enosys()); },
lstat(path, callback) { callback(enosys()); },
mkdir(path, perm, callback) { callback(enosys()); },
open(path, flags, mode, callback) { callback(enosys()); },
read(fd, buffer, offset, length, position, callback) { callback(enosys()); },
readdir(path, callback) { callback(enosys()); },
readlink(path, callback) { callback(enosys()); },
rename(from, to, callback) { callback(enosys()); },
rmdir(path, callback) { callback(enosys()); },
stat(path, callback) { callback(enosys()); },
symlink(path, link, callback) { callback(enosys()); },
truncate(path, length, callback) { callback(enosys()); },
unlink(path, callback) { callback(enosys()); },
utimes(path, atime, mtime, callback) { callback(enosys()); },
};
}
if (!global.process) {
global.process = {
getuid() { return -1; },
getgid() { return -1; },
geteuid() { return -1; },
getegid() { return -1; },
getgroups() { throw enosys(); },
pid: -1,
ppid: -1,
umask() { throw enosys(); },
cwd() { throw enosys(); },
chdir() { throw enosys(); },
}
}
if (!global.crypto) {
const nodeCrypto = require("node:crypto");
global.crypto = {
getRandomValues(b) {
nodeCrypto.randomFillSync(b);
},
};
}
if (!global.performance) {
global.performance = {
now() {
const [sec, nsec] = process.hrtime();
return sec * 1000 + nsec / 1000000;
},
};
}
if (!global.TextEncoder) {
global.TextEncoder = require("node:util").TextEncoder;
}
if (!global.TextDecoder) {
global.TextDecoder = require("node:util").TextDecoder;
}
// End of polyfills for common API.
const encoder = new TextEncoder("utf-8");
const decoder = new TextDecoder("utf-8");
let reinterpretBuf = new DataView(new ArrayBuffer(8));
var logLine = [];
const wasmExit = {}; // thrown to exit via proc_exit (not an error)
global.Go = class {
constructor() {
this._callbackTimeouts = new Map();
this._nextCallbackTimeoutID = 1;
const mem = () => {
// The buffer may change when requesting more memory.
return new DataView(this._inst.exports.memory.buffer);
}
const unboxValue = (v_ref) => {
reinterpretBuf.setBigInt64(0, v_ref, true);
const f = reinterpretBuf.getFloat64(0, true);
if (f === 0) {
return undefined;
}
if (!isNaN(f)) {
return f;
}
const id = v_ref & 0xffffffffn;
return this._values[id];
}
const loadValue = (addr) => {
let v_ref = mem().getBigUint64(addr, true);
return unboxValue(v_ref);
}
const boxValue = (v) => {
const nanHead = 0x7FF80000n;
if (typeof v === "number") {
if (isNaN(v)) {
return nanHead << 32n;
}
if (v === 0) {
return (nanHead << 32n) | 1n;
}
reinterpretBuf.setFloat64(0, v, true);
return reinterpretBuf.getBigInt64(0, true);
}
switch (v) {
case undefined:
return 0n;
case null:
return (nanHead << 32n) | 2n;
case true:
return (nanHead << 32n) | 3n;
case false:
return (nanHead << 32n) | 4n;
}
let id = this._ids.get(v);
if (id === undefined) {
id = this._idPool.pop();
if (id === undefined) {
id = BigInt(this._values.length);
}
this._values[id] = v;
this._goRefCounts[id] = 0;
this._ids.set(v, id);
}
this._goRefCounts[id]++;
let typeFlag = 1n;
switch (typeof v) {
case "string":
typeFlag = 2n;
break;
case "symbol":
typeFlag = 3n;
break;
case "function":
typeFlag = 4n;
break;
}
return id | ((nanHead | typeFlag) << 32n);
}
const storeValue = (addr, v) => {
let v_ref = boxValue(v);
mem().setBigUint64(addr, v_ref, true);
}
const loadSlice = (array, len, cap) => {
return new Uint8Array(this._inst.exports.memory.buffer, array, len);
}
const loadSliceOfValues = (array, len, cap) => {
const a = new Array(len);
for (let i = 0; i < len; i++) {
a[i] = loadValue(array + i * 8);
}
return a;
}
const loadString = (ptr, len) => {
return decoder.decode(new DataView(this._inst.exports.memory.buffer, ptr, len));
}
const timeOrigin = Date.now() - performance.now();
const wasi_EBADF = 8;
const wasi_ENOSYS = 52;
this.importObject = {
wasi_snapshot_preview1: {
// https://github.com/WebAssembly/WASI/blob/snapshot-01/phases/snapshot/docs.md
fd_write: function(fd, iovs_ptr, iovs_len, nwritten_ptr) {
let nwritten = 0;
if (fd == 1) {
for (let iovs_i=0; iovs_i<iovs_len;iovs_i++) {
let iov_ptr = iovs_ptr+iovs_i*8; // assuming wasm32
let ptr = mem().getUint32(iov_ptr + 0, true);
let len = mem().getUint32(iov_ptr + 4, true);
nwritten += len;
for (let i=0; i<len; i++) {
let c = mem().getUint8(ptr+i);
if (c == 13) { // CR
// ignore
} else if (c == 10) { // LF
// write line
let line = decoder.decode(new Uint8Array(logLine));
logLine = [];
console.log(line);
} else {
logLine.push(c);
}
}
}
} else {
console.error('invalid file descriptor:', fd);
}
mem().setUint32(nwritten_ptr, nwritten, true);
return 0;
},
fd_read: () => wasi_ENOSYS,
fd_close: () => wasi_ENOSYS,
fd_fdstat_get: () => wasi_ENOSYS,
fd_prestat_get: () => wasi_EBADF, // wasi-libc relies on this errno value
fd_prestat_dir_name: () => wasi_ENOSYS,
fd_seek: () => wasi_ENOSYS,
path_open: () => wasi_ENOSYS,
proc_exit: (code) => {
this.exited = true;
this.exitCode = code;
this._resolveExitPromise();
throw wasmExit;
},
random_get: (bufPtr, bufLen) => {
crypto.getRandomValues(loadSlice(bufPtr, bufLen));
return 0;
},
},
gojs: {
// func ticks() int64
"runtime.ticks": () => {
return BigInt((timeOrigin + performance.now()) * 1e6);
},
// func sleepTicks(timeout int64)
"runtime.sleepTicks": (timeout) => {
// Do not sleep, only reactivate scheduler after the given timeout.
setTimeout(() => {
if (this.exited) return;
try {
this._inst.exports.go_scheduler();
} catch (e) {
if (e !== wasmExit) throw e;
}
}, Number(timeout)/1e6);
},
// func finalizeRef(v ref)
"syscall/js.finalizeRef": (v_ref) => {
// Note: TinyGo does not support finalizers so this is only called
// for one specific case, by js.go:jsString. and can/might leak memory.
const id = v_ref & 0xffffffffn;
if (this._goRefCounts?.[id] !== undefined) {
this._goRefCounts[id]--;
if (this._goRefCounts[id] === 0) {
const v = this._values[id];
this._values[id] = null;
this._ids.delete(v);
this._idPool.push(id);
}
} else {
console.error("syscall/js.finalizeRef: unknown id", id);
}
},
// func stringVal(value string) ref
"syscall/js.stringVal": (value_ptr, value_len) => {
value_ptr >>>= 0;
const s = loadString(value_ptr, value_len);
return boxValue(s);
},
// func valueGet(v ref, p string) ref
"syscall/js.valueGet": (v_ref, p_ptr, p_len) => {
let prop = loadString(p_ptr, p_len);
let v = unboxValue(v_ref);
let result = Reflect.get(v, prop);
return boxValue(result);
},
// func valueSet(v ref, p string, x ref)
"syscall/js.valueSet": (v_ref, p_ptr, p_len, x_ref) => {
const v = unboxValue(v_ref);
const p = loadString(p_ptr, p_len);
const x = unboxValue(x_ref);
Reflect.set(v, p, x);
},
// func valueDelete(v ref, p string)
"syscall/js.valueDelete": (v_ref, p_ptr, p_len) => {
const v = unboxValue(v_ref);
const p = loadString(p_ptr, p_len);
Reflect.deleteProperty(v, p);
},
// func valueIndex(v ref, i int) ref
"syscall/js.valueIndex": (v_ref, i) => {
return boxValue(Reflect.get(unboxValue(v_ref), i));
},
// valueSetIndex(v ref, i int, x ref)
"syscall/js.valueSetIndex": (v_ref, i, x_ref) => {
Reflect.set(unboxValue(v_ref), i, unboxValue(x_ref));
},
// func valueCall(v ref, m string, args []ref) (ref, bool)
"syscall/js.valueCall": (ret_addr, v_ref, m_ptr, m_len, args_ptr, args_len, args_cap) => {
const v = unboxValue(v_ref);
const name = loadString(m_ptr, m_len);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
try {
const m = Reflect.get(v, name);
storeValue(ret_addr, Reflect.apply(m, v, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr + 8, 0);
}
},
// func valueInvoke(v ref, args []ref) (ref, bool)
"syscall/js.valueInvoke": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
try {
const v = unboxValue(v_ref);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
storeValue(ret_addr, Reflect.apply(v, undefined, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr + 8, 0);
}
},
// func valueNew(v ref, args []ref) (ref, bool)
"syscall/js.valueNew": (ret_addr, v_ref, args_ptr, args_len, args_cap) => {
const v = unboxValue(v_ref);
const args = loadSliceOfValues(args_ptr, args_len, args_cap);
try {
storeValue(ret_addr, Reflect.construct(v, args));
mem().setUint8(ret_addr + 8, 1);
} catch (err) {
storeValue(ret_addr, err);
mem().setUint8(ret_addr+ 8, 0);
}
},
// func valueLength(v ref) int
"syscall/js.valueLength": (v_ref) => {
return unboxValue(v_ref).length;
},
// valuePrepareString(v ref) (ref, int)
"syscall/js.valuePrepareString": (ret_addr, v_ref) => {
const s = String(unboxValue(v_ref));
const str = encoder.encode(s);
storeValue(ret_addr, str);
mem().setInt32(ret_addr + 8, str.length, true);
},
// valueLoadString(v ref, b []byte)
"syscall/js.valueLoadString": (v_ref, slice_ptr, slice_len, slice_cap) => {
const str = unboxValue(v_ref);
loadSlice(slice_ptr, slice_len, slice_cap).set(str);
},
// func valueInstanceOf(v ref, t ref) bool
"syscall/js.valueInstanceOf": (v_ref, t_ref) => {
return unboxValue(v_ref) instanceof unboxValue(t_ref);
},
// func copyBytesToGo(dst []byte, src ref) (int, bool)
"syscall/js.copyBytesToGo": (ret_addr, dest_addr, dest_len, dest_cap, src_ref) => {
let num_bytes_copied_addr = ret_addr;
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
const dst = loadSlice(dest_addr, dest_len);
const src = unboxValue(src_ref);
if (!(src instanceof Uint8Array || src instanceof Uint8ClampedArray)) {
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
mem().setUint8(returned_status_addr, 1); // Return "ok" status
},
// copyBytesToJS(dst ref, src []byte) (int, bool)
// Originally copied from upstream Go project, then modified:
// https://github.com/golang/go/blob/3f995c3f3b43033013013e6c7ccc93a9b1411ca9/misc/wasm/wasm_exec.js#L404-L416
"syscall/js.copyBytesToJS": (ret_addr, dst_ref, src_addr, src_len, src_cap) => {
let num_bytes_copied_addr = ret_addr;
let returned_status_addr = ret_addr + 4; // Address of returned boolean status variable
const dst = unboxValue(dst_ref);
const src = loadSlice(src_addr, src_len);
if (!(dst instanceof Uint8Array || dst instanceof Uint8ClampedArray)) {
mem().setUint8(returned_status_addr, 0); // Return "not ok" status
return;
}
const toCopy = src.subarray(0, dst.length);
dst.set(toCopy);
mem().setUint32(num_bytes_copied_addr, toCopy.length, true);
mem().setUint8(returned_status_addr, 1); // Return "ok" status
},
}
};
// Go 1.20 uses 'env'. Go 1.21 uses 'gojs'.
// For compatibility, we use both as long as Go 1.20 is supported.
this.importObject.env = this.importObject.gojs;
}
async run(instance) {
this._inst = instance;
this._values = [ // JS values that Go currently has references to, indexed by reference id
NaN,
0,
null,
true,
false,
global,
this,
];
this._goRefCounts = []; // number of references that Go has to a JS value, indexed by reference id
this._ids = new Map(); // mapping from JS values to reference ids
this._idPool = []; // unused ids that have been garbage collected
this.exited = false; // whether the Go program has exited
this.exitCode = 0;
if (this._inst.exports._start) {
let exitPromise = new Promise((resolve, reject) => {
this._resolveExitPromise = resolve;
});
// Run program, but catch the wasmExit exception that's thrown
// to return back here.
try {
this._inst.exports._start();
} catch (e) {
if (e !== wasmExit) throw e;
}
await exitPromise;
return this.exitCode;
} else {
this._inst.exports._initialize();
}
}
_resume() {
if (this.exited) {
throw new Error("Go program has already exited");
}
try {
this._inst.exports.resume();
} catch (e) {
if (e !== wasmExit) throw e;
}
if (this.exited) {
this._resolveExitPromise();
}
}
_makeFuncWrapper(id) {
const go = this;
return function () {
const event = { id: id, this: this, args: arguments };
go._pendingEvent = event;
go._resume();
return event.result;
};
}
}
if (
global.require &&
global.require.main === module &&
global.process &&
global.process.versions &&
!global.process.versions.electron
) {
if (process.argv.length != 3) {
console.error("usage: go_js_wasm_exec [wasm binary] [arguments]");
process.exit(1);
}
const go = new Go();
WebAssembly.instantiate(fs.readFileSync(process.argv[2]), go.importObject).then(async (result) => {
let exitCode = await go.run(result.instance);
process.exit(exitCode);
}).catch((err) => {
console.error(err);
process.exit(1);
});
}
})();
+211
View File
@@ -0,0 +1,211 @@
// Verifies the orchestration order in `GalaxyClient.executeCommand`:
//
// core.signRequest(fields)
// → signer(canonicalBytes)
// → edge.executeCommand({ envelope, signature })
// → core.verifyPayloadHash(response payload, hash)
// → core.verifyResponse(envelope, signature)
// → return response.payloadBytes
//
// Tests use a hand-rolled mock `Core` and a Connect router transport
// from `@connectrpc/connect`, which lets us assert what the gateway
// would have received without standing up a real Connect server.
import { create } from "@bufbuild/protobuf";
import { createClient, createRouterTransport } from "@connectrpc/connect";
import { describe, expect, test, vi } from "vitest";
import { GalaxyClient } from "../src/api/galaxy-client";
import {
EdgeGateway,
ExecuteCommandResponseSchema,
type ExecuteCommandRequest,
} from "../src/proto/galaxy/gateway/v1/edge_gateway_pb";
import type {
Core,
RequestSigningFields,
ResponseSigningFields,
} from "../src/platform/core/index";
const FIXED_REQUEST_ID = "req-test-1";
const FIXED_TIMESTAMP = 1_700_000_000_000n;
describe("GalaxyClient.executeCommand", () => {
test("orchestrates sign → fetch → verify and returns the payload", async () => {
const canonicalBytes = new Uint8Array([0xca, 0xfe]);
const signature = new Uint8Array(64).fill(0x55);
const responsePayload = new TextEncoder().encode("hello-from-server");
const responseHash = new Uint8Array(32).fill(0x77);
const responseSignature = new Uint8Array(64).fill(0x99);
const responsePublicKey = new Uint8Array(32).fill(0x11);
const core = mockCore({
signRequestImpl: () => canonicalBytes,
verifyResponseImpl: () => true,
verifyPayloadHashImpl: () => true,
});
const signer = vi.fn(async () => signature);
const sha256 = vi.fn(async () => new Uint8Array(32).fill(0x33));
let captured: ExecuteCommandRequest | undefined;
const transport = createRouterTransport(({ service }) => {
service(EdgeGateway, {
executeCommand(req) {
captured = req;
return create(ExecuteCommandResponseSchema, {
protocolVersion: "v1",
requestId: FIXED_REQUEST_ID,
timestampMs: FIXED_TIMESTAMP,
resultCode: "ok",
payloadBytes: responsePayload,
payloadHash: responseHash,
signature: responseSignature,
});
},
subscribeEvents() {
throw new Error("not used in this test");
},
});
});
const edge = createClient(EdgeGateway, transport);
const client = new GalaxyClient({
core,
edge,
signer,
sha256,
deviceSessionId: "device-session-1",
gatewayResponsePublicKey: responsePublicKey,
clock: () => FIXED_TIMESTAMP,
requestIdFactory: () => FIXED_REQUEST_ID,
});
const out = await client.executeCommand(
"user.account.get",
new TextEncoder().encode("client-payload"),
);
expect(Array.from(out)).toEqual(Array.from(responsePayload));
expect(signer).toHaveBeenCalledWith(canonicalBytes);
expect(sha256).toHaveBeenCalledTimes(1);
expect(core.signRequest).toHaveBeenCalledTimes(1);
const verifyHashCall = vi.mocked(core.verifyPayloadHash).mock.calls[0]!;
expect(Array.from(verifyHashCall[0])).toEqual(Array.from(responsePayload));
expect(Array.from(verifyHashCall[1])).toEqual(Array.from(responseHash));
const verifyRespCall = vi.mocked(core.verifyResponse).mock.calls[0]!;
expect(Array.from(verifyRespCall[0])).toEqual(Array.from(responsePublicKey));
expect(Array.from(verifyRespCall[1])).toEqual(Array.from(responseSignature));
expect(verifyRespCall[2]).toMatchObject({
protocolVersion: "v1",
requestId: FIXED_REQUEST_ID,
resultCode: "ok",
});
expect(captured?.deviceSessionId).toBe("device-session-1");
expect(captured?.messageType).toBe("user.account.get");
expect(captured?.requestId).toBe(FIXED_REQUEST_ID);
expect(Array.from(captured?.signature ?? [])).toEqual(
Array.from(signature),
);
});
test("throws when the response signature fails verification", async () => {
const core = mockCore({
signRequestImpl: () => new Uint8Array(),
verifyResponseImpl: () => false,
verifyPayloadHashImpl: () => true,
});
const transport = createRouterTransport(({ service }) => {
service(EdgeGateway, {
executeCommand: () =>
create(ExecuteCommandResponseSchema, {
protocolVersion: "v1",
requestId: FIXED_REQUEST_ID,
timestampMs: FIXED_TIMESTAMP,
resultCode: "ok",
payloadBytes: new Uint8Array(),
payloadHash: new Uint8Array(32),
signature: new Uint8Array(64),
}),
subscribeEvents() {
throw new Error("not used in this test");
},
});
});
const client = new GalaxyClient({
core,
edge: createClient(EdgeGateway, transport),
signer: async () => new Uint8Array(64),
sha256: async () => new Uint8Array(32),
deviceSessionId: "device-session-1",
gatewayResponsePublicKey: new Uint8Array(32),
clock: () => FIXED_TIMESTAMP,
requestIdFactory: () => FIXED_REQUEST_ID,
});
await expect(
client.executeCommand("user.account.get", new Uint8Array()),
).rejects.toThrow(/invalid response signature/);
});
test("throws when the response payload_hash does not match", async () => {
const core = mockCore({
signRequestImpl: () => new Uint8Array(),
verifyResponseImpl: () => true,
verifyPayloadHashImpl: () => false,
});
const transport = createRouterTransport(({ service }) => {
service(EdgeGateway, {
executeCommand: () =>
create(ExecuteCommandResponseSchema, {
protocolVersion: "v1",
requestId: FIXED_REQUEST_ID,
timestampMs: FIXED_TIMESTAMP,
resultCode: "ok",
payloadBytes: new Uint8Array(),
payloadHash: new Uint8Array(32),
signature: new Uint8Array(64),
}),
subscribeEvents() {
throw new Error("not used in this test");
},
});
});
const client = new GalaxyClient({
core,
edge: createClient(EdgeGateway, transport),
signer: async () => new Uint8Array(64),
sha256: async () => new Uint8Array(32),
deviceSessionId: "device-session-1",
gatewayResponsePublicKey: new Uint8Array(32),
clock: () => FIXED_TIMESTAMP,
requestIdFactory: () => FIXED_REQUEST_ID,
});
await expect(
client.executeCommand("user.account.get", new Uint8Array()),
).rejects.toThrow(/payload_hash mismatch/);
});
});
interface MockCoreOptions {
signRequestImpl: (fields: RequestSigningFields) => Uint8Array;
verifyResponseImpl: (
publicKey: Uint8Array,
signature: Uint8Array,
fields: ResponseSigningFields,
) => boolean;
verifyPayloadHashImpl: (
payload: Uint8Array,
hash: Uint8Array,
) => boolean;
}
function mockCore(opts: MockCoreOptions): Core & {
signRequest: ReturnType<typeof vi.fn>;
verifyResponse: ReturnType<typeof vi.fn>;
verifyPayloadHash: ReturnType<typeof vi.fn>;
verifyEvent: ReturnType<typeof vi.fn>;
} {
return {
signRequest: vi.fn(opts.signRequestImpl),
verifyResponse: vi.fn(opts.verifyResponseImpl),
verifyEvent: vi.fn(() => true),
verifyPayloadHash: vi.fn(opts.verifyPayloadHashImpl),
};
}
+51
View File
@@ -0,0 +1,51 @@
// Boots the TinyGo `core.wasm` module under Vitest/JSDOM by reading
// the artefact and the matching wasm_exec.js shim from disk, evaluating
// the shim into the test's global scope, then instantiating WebAssembly
// against `(new Go()).importObject`. Returns a `Core` ready to use in
// tests; subsequent calls return the cached instance so each Vitest
// file pays the boot cost once.
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { runInThisContext } from "node:vm";
import { adaptBridge, requireBridge } from "../src/platform/core/wasm";
import type { Core } from "../src/platform/core/index";
// Vitest is launched from ui/frontend, so the static artefacts produced
// by `make wasm` sit at <cwd>/static/. Resolving via process.cwd avoids
// the JSDOM-specific "URL must be of scheme file" error that
// import.meta.url triggers under the jsdom test environment.
const STATIC_DIR = resolve(process.cwd(), "static") + "/";
let cached: Promise<Core> | undefined;
export function loadWasmCoreForTest(): Promise<Core> {
if (!cached) {
cached = boot();
}
return cached;
}
async function boot(): Promise<Core> {
if (typeof globalThis.Go === "undefined") {
const shim = readFileSync(`${STATIC_DIR}wasm_exec.js`, "utf8");
runInThisContext(shim);
}
const Go = globalThis.Go;
if (!Go) {
throw new Error("setup-wasm: Go runtime missing after wasm_exec.js eval");
}
const go = new Go();
const wasm = readFileSync(`${STATIC_DIR}core.wasm`);
const { instance } = await WebAssembly.instantiate(
wasm,
go.importObject,
);
void go.run(instance);
// `go.run` is async but the Go side registers `globalThis.galaxyCore`
// and then blocks on `select {}`; the registration happens
// synchronously before the first await, so by the time the next
// microtask runs the bridge is already available.
await new Promise<void>((resolve) => setTimeout(resolve, 0));
return adaptBridge(requireBridge());
}
@@ -0,0 +1,195 @@
// Asserts that the WASM-compiled `ui/core` produces canonical bytes
// byte-for-byte identical to the gateway-side fixtures committed in
// `ui/core/canon/testdata/`. The fixtures themselves are exercised by
// the Go-side parity test in `gateway/authn/parity_with_ui_core_test.go`,
// so passing both proves Go-Go parity AND Go-TS parity through WASM.
//
// The signature parity check additionally proves that a signature
// produced by Node's `ed25519` over the WASM-built canonical bytes is
// accepted by the gateway's `VerifyResponseSignature` /
// `VerifyEventSignature` (re-checked here via Core.verifyResponse /
// Core.verifyEvent against the same fixture key).
import {
createPrivateKey,
createPublicKey,
sign as cryptoSign,
} from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { beforeAll, describe, expect, test } from "vitest";
import type { Core } from "../src/platform/core/index";
import { loadWasmCoreForTest } from "./setup-wasm";
const TESTDATA_DIR = resolve(
process.cwd(),
"..",
"core",
"canon",
"testdata",
) + "/";
interface RequestFixture {
protocol_version: string;
device_session_id: string;
message_type: string;
timestamp_ms: number;
request_id: string;
payload: string;
payload_hash_hex: string;
expected_canonical_bytes_hex: string;
private_key_seed_hex: string;
public_key_base64: string;
expected_signature_hex: string;
}
interface ResponseFixture {
protocol_version: string;
request_id: string;
timestamp_ms: number;
result_code: string;
payload: string;
payload_hash_hex: string;
expected_canonical_bytes_hex: string;
private_key_seed_hex: string;
public_key_base64: string;
expected_signature_hex: string;
}
interface EventFixture {
event_type: string;
event_id: string;
timestamp_ms: number;
request_id: string;
trace_id: string;
payload: string;
payload_hash_hex: string;
expected_canonical_bytes_hex: string;
private_key_seed_hex: string;
public_key_base64: string;
expected_signature_hex: string;
}
let core: Core;
beforeAll(async () => {
core = await loadWasmCoreForTest();
});
describe("WasmCore canon parity with gateway fixtures", () => {
test.each([
"request_user_account_get.json",
"request_user_games_command.json",
"request_lobby_my_games_list.json",
])("%s — canonical bytes byte-for-byte equal", (name) => {
const fixture = readJson<RequestFixture>(name);
const canonical = core.signRequest({
protocolVersion: fixture.protocol_version,
deviceSessionId: fixture.device_session_id,
messageType: fixture.message_type,
timestampMs: BigInt(fixture.timestamp_ms),
requestId: fixture.request_id,
payloadHash: hexToBytes(fixture.payload_hash_hex),
});
expect(bytesToHex(canonical)).toBe(fixture.expected_canonical_bytes_hex);
const signature = signEd25519(
hexToBytes(fixture.private_key_seed_hex),
canonical,
);
expect(bytesToHex(signature)).toBe(fixture.expected_signature_hex);
});
test("response_ok.json — verifyResponse accepts canonical signature", () => {
const fixture = readJson<ResponseFixture>("response_ok.json");
const publicKey = base64ToBytes(fixture.public_key_base64);
const signature = hexToBytes(fixture.expected_signature_hex);
const fields = {
protocolVersion: fixture.protocol_version,
requestId: fixture.request_id,
timestampMs: BigInt(fixture.timestamp_ms),
resultCode: fixture.result_code,
payloadHash: hexToBytes(fixture.payload_hash_hex),
};
expect(core.verifyResponse(publicKey, signature, fields)).toBe(true);
const tampered = new Uint8Array(signature);
tampered[0] ^= 0xff;
expect(core.verifyResponse(publicKey, tampered, fields)).toBe(false);
});
test("event_gateway_server_time.json — verifyEvent accepts canonical signature", () => {
const fixture = readJson<EventFixture>("event_gateway_server_time.json");
const publicKey = base64ToBytes(fixture.public_key_base64);
const signature = hexToBytes(fixture.expected_signature_hex);
const fields = {
eventType: fixture.event_type,
eventId: fixture.event_id,
timestampMs: BigInt(fixture.timestamp_ms),
requestId: fixture.request_id,
traceId: fixture.trace_id,
payloadHash: hexToBytes(fixture.payload_hash_hex),
};
expect(core.verifyEvent(publicKey, signature, fields)).toBe(true);
const tampered = new Uint8Array(signature);
tampered[0] ^= 0xff;
expect(core.verifyEvent(publicKey, tampered, fields)).toBe(false);
});
});
function readJson<T>(name: string): T {
return JSON.parse(readFileSync(`${TESTDATA_DIR}${name}`, "utf8")) as T;
}
function hexToBytes(value: string): Uint8Array {
const length = value.length / 2;
const out = new Uint8Array(length);
for (let i = 0; i < length; i++) {
out[i] = parseInt(value.substr(i * 2, 2), 16);
}
return out;
}
function bytesToHex(value: Uint8Array): string {
return Array.from(value, (byte) =>
byte.toString(16).padStart(2, "0"),
).join("");
}
function base64ToBytes(value: string): Uint8Array {
return Uint8Array.from(Buffer.from(value, "base64"));
}
// Node's `ed25519` accepts a raw 32-byte seed packaged as a PKCS#8 DER
// blob; this helper synthesises that blob so the test fixture seed
// matches the Go-side `ed25519.NewKeyFromSeed` shape used by the
// committed canon golden values.
function signEd25519(seed: Uint8Array, message: Uint8Array): Uint8Array {
const pkcs8 = pkcs8FromEd25519Seed(seed);
const key = createPrivateKey({ key: pkcs8, format: "der", type: "pkcs8" });
const signature = cryptoSign(null, message, key);
return new Uint8Array(signature);
}
function pkcs8FromEd25519Seed(seed: Uint8Array): Buffer {
if (seed.length !== 32) {
throw new Error(`ed25519 seed must be 32 bytes, got ${seed.length}`);
}
// PKCS#8 wrapping for an Ed25519 raw seed:
// SEQUENCE {
// INTEGER 0,
// SEQUENCE { OID 1.3.101.112 (Ed25519) },
// OCTET STRING { OCTET STRING { seed } }
// }
const prefix = Buffer.from([
0x30, 0x2e, 0x02, 0x01, 0x00, 0x30, 0x05, 0x06, 0x03, 0x2b, 0x65, 0x70,
0x04, 0x22, 0x04, 0x20,
]);
return Buffer.concat([prefix, Buffer.from(seed)]);
}
// `createPublicKey` is imported only so future tests can derive the
// public key from the same seed without going through the WASM bridge.
// Suppress the unused-import warning by referencing the binding here.
void createPublicKey;
+94
View File
@@ -0,0 +1,94 @@
import { createHash } from "node:crypto";
import { beforeAll, describe, expect, test } from "vitest";
import type { Core } from "../src/platform/core/index";
import { loadWasmCoreForTest } from "./setup-wasm";
let core: Core;
beforeAll(async () => {
core = await loadWasmCoreForTest();
});
describe("WasmCore.signRequest", () => {
test("returns canonical bytes that lead with the v1 domain marker", () => {
const fields = {
protocolVersion: "v1",
deviceSessionId: "device-session-1",
messageType: "user.account.get",
timestampMs: 1700000000000n,
requestId: "req-1",
payloadHash: sha256("payload"),
};
const canonical = core.signRequest(fields);
const marker = "galaxy-request-v1";
expect(canonical.length).toBeGreaterThan(marker.length);
expect(new TextDecoder().decode(canonical.slice(1, 1 + marker.length))).toBe(
marker,
);
});
});
describe("WasmCore.verifyResponse", () => {
test("rejects a flipped-bit signature", () => {
const publicKey = new Uint8Array(32);
const signature = new Uint8Array(64);
const fields = {
protocolVersion: "v1",
requestId: "req-1",
timestampMs: 1700000000000n,
resultCode: "ok",
payloadHash: sha256("payload"),
};
expect(core.verifyResponse(publicKey, signature, fields)).toBe(false);
});
test("returns false for malformed key/signature lengths", () => {
const fields = {
protocolVersion: "v1",
requestId: "req-1",
timestampMs: 1700000000000n,
resultCode: "ok",
payloadHash: sha256("payload"),
};
expect(
core.verifyResponse(new Uint8Array(8), new Uint8Array(64), fields),
).toBe(false);
});
});
describe("WasmCore.verifyEvent", () => {
test("rejects an empty signature", () => {
const fields = {
eventType: "gateway.server_time",
eventId: "evt-1",
timestampMs: 1700000000000n,
requestId: "req-1",
traceId: "trace-1",
payloadHash: sha256("event"),
};
expect(
core.verifyEvent(new Uint8Array(32), new Uint8Array(0), fields),
).toBe(false);
});
});
describe("WasmCore.verifyPayloadHash", () => {
test("accepts the SHA-256 of the supplied payload", () => {
const payload = new TextEncoder().encode("hello");
expect(core.verifyPayloadHash(payload, sha256("hello"))).toBe(true);
});
test("rejects a digest of unrelated bytes", () => {
const payload = new TextEncoder().encode("hello");
expect(core.verifyPayloadHash(payload, sha256("world"))).toBe(false);
});
test("rejects a payload_hash whose length is not 32", () => {
const payload = new TextEncoder().encode("hello");
expect(core.verifyPayloadHash(payload, new Uint8Array(16))).toBe(false);
});
});
function sha256(value: string): Uint8Array {
return new Uint8Array(createHash("sha256").update(value).digest());
}