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:
@@ -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;
|
||||
Reference in New Issue
Block a user