Files
galaxy-game/ui/frontend/tests/wasm-core-canon-parity.test.ts
T
Ilia Denisov fbc0260720 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>
2026-05-07 12:58:37 +02:00

196 lines
6.2 KiB
TypeScript

// 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;