diff --git a/Directory.Build.props b/Directory.Build.props index db26d4f3..434617ff 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -15,6 +15,7 @@ © Meta Vens and contributors true 1.0.33 + $(CultLibPackageVersion) true diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index bbc253f4..a0596ae2 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -24,7 +24,32 @@ export interface CultMeshBrowserIdentity { export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity { endpoint: string; protocolId?: string; + protocolIds?: readonly string[]; + priority?: number; generation: string; + certificate?: CultMeshBrowserRouteCertificate; +} + +export interface CultMeshBrowserP256PublicKey { + keyId: string; + x: string; + y: string; +} + +export interface CultMeshBrowserRouteCertificate { + providerKey: CultMeshBrowserP256PublicKey; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development"; + +export interface CultMeshBrowserAuthorityTrustPolicy { + mode: CultMeshBrowserAuthorityTrustMode; + odinRoots?: readonly CultMeshBrowserP256PublicKey[]; + now?: () => number; } export interface CultMeshBrowserRendezvous { @@ -162,6 +187,8 @@ export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity { requestTimeoutMs?: number; maxFrameBytes?: number; createId?: () => string; + /** Consumer-owned authority roots. Remote sessions fail closed when omitted. */ + trust?: CultMeshBrowserAuthorityTrustPolicy; } export interface CultMeshRawDocumentLeaseOptions { @@ -215,6 +242,7 @@ interface PendingSnapshot { } interface PendingOperation { + request: CultNetOperationRequestMessage; resolve: (response: CultNetOperationResponseMessage) => void; reject: (error: Error) => void; timer: ReturnType; @@ -297,6 +325,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024, createId: options.createId ?? (() => crypto.randomUUID()), socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)), + trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] }, }; } @@ -369,15 +398,12 @@ export class CultMeshBrowserClient implements AsyncDisposable { if (!this.#pendingOperations.delete(messageId)) return; reject(new Error(`CultMesh operation '${messageId}' timed out.`)); }, this.#options.requestTimeoutMs); - this.#pendingOperations.set(messageId, { resolve, reject, timer }); + this.#pendingOperations.set(messageId, { request, resolve, reject, timer }); }); try { this.send(request); } catch (error) { - const pending = this.#pendingOperations.get(messageId); - if (pending) clearTimeout(pending.timer); - this.#pendingOperations.delete(messageId); - throw error; + this.scheduleReconnect(); } return response; } @@ -490,6 +516,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { route.authorityRuntimeId !== this.identity.authorityRuntimeId) { throw new Error("CultMesh rendezvous returned a route for the wrong stable identity."); } + await verifyAuthorityRoute(route, this.#options.trust); const endpoint = new URL(route.endpoint); if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") { throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`); @@ -501,6 +528,17 @@ export class CultMeshBrowserClient implements AsyncDisposable { let opened = false; let settled = false; const handshakeId = this.#options.createId(); + const clientNonce = randomNonce(); + const sessionRequest: CultMeshSessionOpenMessage = { + schemaVersion: "cultmesh.session_open.v2", + messageId: handshakeId, + sourceRuntimeId: this.runtimeId, + verseId: this.identity.verseId, + authorityRuntimeId: this.identity.authorityRuntimeId, + protocolId: route.protocolId ?? "cultmesh.documents.v1", + routeGeneration: route.generation, + clientNonce, + }; const finish = (error?: Error) => { if (settled) return; settled = true; @@ -524,8 +562,8 @@ export class CultMeshBrowserClient implements AsyncDisposable { runtimeId: this.runtimeId, runtimeKind: "browser", supportedMessageVersions: [ - "cultmesh.session_open.v1", - "cultmesh.session_accepted.v1", + "cultmesh.session_open.v2", + "cultmesh.session_accepted.v2", "cultnet.database_subscribe.v0", "cultnet.database_unsubscribe.v0", "cultnet.database_change_raw.v0", @@ -533,16 +571,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { "cultnet.operation_response.v0", ], }, this.#options.maxFrameBytes); - const request: CultMeshSessionOpenMessage = { - schemaVersion: "cultmesh.session_open.v1", - messageId: handshakeId, - sourceRuntimeId: this.runtimeId, - verseId: this.identity.verseId, - authorityRuntimeId: this.identity.authorityRuntimeId, - protocolId: route.protocolId ?? "cultmesh.documents.v1", - routeGeneration: route.generation, - }; - sendSocketMessage(socket, request, this.#options.maxFrameBytes); + sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes); } catch (error) { finish(error instanceof Error ? error : new Error(String(error))); } @@ -553,8 +582,13 @@ export class CultMeshBrowserClient implements AsyncDisposable { finish(new Error(message.error)); return; } - if (message.schemaVersion !== "cultmesh.session_accepted.v1" || message.messageId !== handshakeId) return; - finish(validateSessionAcceptance(message, this.identity, route)); + if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return; + void validateSessionAcceptance( + message, + sessionRequest, + route, + this.#options.trust, + ).then(finish).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); }).catch(error => finish(error instanceof Error ? error : new Error(String(error)))); }; socket.onerror = () => finish(new Error(`CultMesh WebSocket could not open '${route.endpoint}'.`)); @@ -575,6 +609,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { this.setState("connected"); try { await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease))); + for (const pending of this.#pendingOperations.values()) this.send(pending.request); } catch (error) { if (this.#socket === socket) this.#socket = undefined; socket.onclose = null; @@ -591,12 +626,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { clearTimeout(pending.timer); pending.reject(error); } - for (const pending of this.#pendingOperations.values()) { - clearTimeout(pending.timer); - pending.reject(error); - } this.#pendingSnapshots.clear(); - this.#pendingOperations.clear(); this.setState("reconnecting"); this.#reconnectTimer = setTimeout(() => { this.#reconnectTimer = undefined; @@ -713,6 +743,15 @@ export class CultMeshBrowserClient implements AsyncDisposable { for (const watcher of this.#stateWatchers) watcher(state); } + private scheduleReconnect(): void { + if (this.#disposed || this.#reconnectTimer) return; + this.setState("reconnecting"); + this.#reconnectTimer = setTimeout(() => { + this.#reconnectTimer = undefined; + void this.connectSocket().catch(() => this.scheduleReconnect()); + }, this.#options.reconnectDelayMs); + } + private throwIfDisposed(): void { if (this.#disposed) throw new Error("CultMesh browser client is disposed."); } @@ -816,6 +855,7 @@ function selectOdinRoute( protocolIds: ["cultmesh.documents.v1"], priority: 0, generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"), + certificate: undefined, })); const routes = boundRoutes.filter(route => route.authorityRuntimeId === identity.authorityRuntimeId && @@ -836,29 +876,171 @@ function selectOdinRoute( ...identity, endpoint: route.endpoint, protocolId: "cultmesh.documents.v1", + protocolIds: route.protocolIds, + priority: route.priority, generation: route.generation, + ...(route.certificate ? { certificate: { + providerKey: { + keyId: route.certificate.providerKeyId, + x: route.certificate.providerPublicKeyX, + y: route.certificate.providerPublicKeyY, + }, + odinKeyId: route.certificate.odinKeyId, + issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds, + expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds, + signature: route.certificate.signature, + } } : {}), }; } -function validateSessionAcceptance( +async function validateSessionAcceptance( message: CultMeshSessionAcceptedMessage, - identity: CultMeshBrowserIdentity, + request: CultMeshSessionOpenMessage, route: CultMeshBrowserRoute, -): Error | undefined { + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session."); const protocolId = route.protocolId ?? "cultmesh.documents.v1"; - if (message.verseId !== identity.verseId || - message.authorityRuntimeId !== identity.authorityRuntimeId || + if (message.verseId !== request.verseId || + message.authorityRuntimeId !== request.authorityRuntimeId || message.protocolId !== protocolId || - message.routeGeneration !== route.generation) { + message.routeGeneration !== route.generation || + message.clientNonce !== request.clientNonce) { return new Error( `CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` + - `expected '${identity.verseId}/${identity.authorityRuntimeId}/${protocolId}/${route.generation}'.`, + `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, ); } + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined; + const providerKey = route.certificate?.providerKey; + if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) { + return new Error("CultMesh authority did not prove possession of the Odin-certified provider key."); + } + if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) { + return new Error("CultMesh provider session proof is invalid."); + } return undefined; } +async function verifyAuthorityRoute( + route: CultMeshBrowserRoute, + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { + const certificate = route.certificate; + if (!certificate) { + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; + throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); + } + const endpoint = new URL(route.endpoint); + if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { + throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection."); + } + const now = trust.now?.() ?? Date.now(); + if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) { + throw new Error("The Odin route certificate is not currently valid."); + } + const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId); + if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`); + if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) { + throw new Error("The Odin route certificate signature is invalid."); + } +} + +function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array { + const certificate = route.certificate!; + return canonicalFields( + "gamecult.cultmesh.route-certificate.v1", + route.verseId, + route.authorityRuntimeId, + route.endpoint, + [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"), + String(route.priority ?? 0), + route.generation, + certificate.providerKey.keyId, + certificate.providerKey.x, + certificate.providerKey.y, + certificate.odinKeyId, + String(certificate.issuedAtUnixMilliseconds), + String(certificate.expiresAtUnixMilliseconds), + ); +} + +function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { + return canonicalFields( + "gamecult.cultmesh.session-proof.v1", + request.clientNonce, + request.messageId, + request.sourceRuntimeId, + request.verseId, + request.authorityRuntimeId, + request.protocolId, + endpoint, + request.routeGeneration, + ); +} + +function canonicalFields(...values: string[]): Uint8Array { + const encoder = new TextEncoder(); + const encoded = values.map(value => encoder.encode(value)); + const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0); + const result = new Uint8Array(total); + const view = new DataView(result.buffer); + let offset = 0; + for (const value of encoded) { + view.setUint32(offset, value.byteLength, false); + offset += 4; + result.set(value, offset); + offset += value.byteLength; + } + return result; +} + +async function verifyP256( + key: CultMeshBrowserP256PublicKey, + payload: Uint8Array, + signatureBase64: string, +): Promise { + try { + const x = base64ToBytes(key.x); + const y = base64ToBytes(key.y); + const signature = base64ToBytes(signatureBase64); + if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false; + const raw = new Uint8Array(65); + raw[0] = 4; + raw.set(x, 1); + raw.set(y, 33); + const publicKey = await crypto.subtle.importKey( + "raw", + raw.slice().buffer as ArrayBuffer, + { name: "ECDSA", namedCurve: "P-256" }, + false, + ["verify"], + ); + return await crypto.subtle.verify( + { name: "ECDSA", hash: "SHA-256" }, + publicKey, + signature.slice().buffer as ArrayBuffer, + payload.slice().buffer as ArrayBuffer, + ); + } catch { + return false; + } +} + +function randomNonce(): string { + const bytes = crypto.getRandomValues(new Uint8Array(32)); + return bytesToBase64(bytes); +} + +function isLoopbackEndpoint(value: string): boolean { + try { + const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase(); + return host === "localhost" || host === "127.0.0.1" || host === "::1"; + } catch { + return false; + } +} + function requireText(value: string, field: string): void { if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`); } diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index a785d47c..4f90acf1 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -19,9 +19,12 @@ import { decodeCultNetOperationPayload, decodeCultNetPayload, type CultMeshBrowserRoute, + type CultMeshBrowserP256PublicKey, type CultMeshBrowserSocket, } from "../src/index.js"; +const localTrust = { mode: "local-development" as const }; + test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => { const odin = new FakeOdin("ws://127.0.0.1:4050/mesh"); let id = 0; @@ -98,6 +101,7 @@ test("browser client leases provider state, invokes an operation, and follows ro verseId: route.verseId, authorityRuntimeId: route.authorityRuntimeId, runtimeId: "browser-test", + trust: localTrust, rendezvous: { resolve: async () => route }, createId: () => `id-${++id}`, socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(), @@ -144,6 +148,7 @@ test("browser client rejects wrong rendezvous identity and has no document write verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", runtimeId: "browser-test", + trust: localTrust, rendezvous: { resolve: async () => ({ ...provider.route, @@ -165,6 +170,7 @@ test("browser client rejects a route whose peer proves the wrong authority", asy verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", runtimeId: "browser-peer-proof-test", + trust: localTrust, rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) }, socketFactory: () => provider.open(), }), @@ -172,6 +178,56 @@ test("browser client rejects a route whose peer proves the wrong authority", asy ); }); +test("browser client accepts an Odin-certified route only after provider nonce proof", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + const client = await CultMeshBrowserClient.connect({ + ...fixture.route, + runtimeId: "browser-authenticated-test", + rendezvous: { resolve: async () => fixture.route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, + socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate), + }); + + assert.equal(client.state, "connected"); + await client.dispose(); +}); + +test("browser client rejects a credential-free authority impersonator", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + const impersonator = new FakeProvider("wss://provider.example/mesh", 1); + + await assert.rejects( + CultMeshBrowserClient.connect({ + ...fixture.route, + runtimeId: "browser-impersonation-test", + rendezvous: { resolve: async () => fixture.route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] }, + socketFactory: () => impersonator.open(), + }), + /did not prove possession/, + ); +}); + +test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => { + const fixture = await createSignedRoute("wss://provider.example/mesh"); + let opened = false; + const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({ + ...route, + runtimeId: "browser-route-certificate-test", + rendezvous: { resolve: async () => route }, + trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now }, + socketFactory: () => { + opened = true; + return signedHandshakeSocket(route, fixture.providerPrivate); + }, + }); + + await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/); + assert.equal(opened, false); + await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/); + assert.equal(opened, false); +}); + test("browser client bounds unanswered provider operations", async () => { const route = { verseId: "sample.counter", @@ -182,6 +238,7 @@ test("browser client bounds unanswered provider operations", async () => { const client = await CultMeshBrowserClient.connect({ ...route, runtimeId: "browser-timeout-test", + trust: localTrust, rendezvous: { resolve: async () => route }, requestTimeoutMs: 5, socketFactory: () => handshakeOnlySocket(route), @@ -204,6 +261,7 @@ test("browser client rejects a correlated framework failure without waiting for const client = await CultMeshBrowserClient.connect({ ...provider.route, runtimeId: "browser-failure-test", + trust: localTrust, rendezvous: { resolve: async () => provider.route }, requestTimeoutMs: 60_000, socketFactory: () => provider.open(), @@ -228,6 +286,82 @@ test("browser client rejects a correlated framework failure without waiting for await client.dispose(); }); +test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => { + const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1"); + const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2"); + let route = firstRoute; + let effectCount = 0; + let dropFirstResponse = true; + const results = new Map(); + const open = () => { + let socket: FakeSocket; + socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion === "cultmesh.session_open.v2") { + socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: message.verseId, + authorityRuntimeId: message.authorityRuntimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", + }); + return; + } + if (message.schemaVersion !== "cultnet.operation_request.v0") return; + let response = results.get(message.messageId); + if (!response) { + effectCount++; + response = { + schemaVersion: "cultnet.operation_response.v0", + messageId: message.messageId, + serviceId: message.serviceId, + operation: message.operation, + status: "accepted", + payloadSchema: "sample.increment_receipt.v1", + payloadEncoding: "messagepack-base64", + payload: Buffer.from(encode({ count: effectCount })).toString("base64"), + sourceRuntimeId: "sample.counter-daemon", + }; + results.set(message.messageId, response!); + } + if (dropFirstResponse) { + dropFirstResponse = false; + route = secondRoute; + socket.close(1012, "route lost after commit"); + return; + } + socket.deliver(response!); + }, () => undefined); + return socket; + }; + const client = await CultMeshBrowserClient.connect({ + ...firstRoute, + runtimeId: "browser-operation-replay-test", + rendezvous: { resolve: async () => route }, + trust: localTrust, + reconnectDelayMs: 1, + requestTimeoutMs: 2_000, + socketFactory: () => open(), + }); + + const response = await client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + idempotencyKey: "replay-once", + }); + + assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 }); + assert.equal(effectCount, 1); + await client.dispose(); +}); + test("browser client rejects an operation response from another runtime", async () => { const provider = new FakeProvider( "ws://127.0.0.1:4371/mesh", @@ -239,6 +373,7 @@ test("browser client rejects an operation response from another runtime", async const client = await CultMeshBrowserClient.connect({ ...provider.route, runtimeId: "browser-response-authority-test", + trust: localTrust, rendezvous: { resolve: async () => provider.route }, socketFactory: () => provider.open(), }); @@ -266,6 +401,7 @@ test("browser client rejects oversized outbound schema messages", async () => { CultMeshBrowserClient.connect({ ...route, runtimeId: "browser-size-test", + trust: localTrust, rendezvous: { resolve: async () => route }, maxFrameBytes: 8, socketFactory: () => new FakeSocket(() => undefined, () => undefined), @@ -279,6 +415,7 @@ test("browser client reserves subscription identity while the initial lease open const client = await CultMeshBrowserClient.connect({ ...provider.route, runtimeId: "browser-lease-race-test", + trust: localTrust, rendezvous: { resolve: async () => provider.route }, socketFactory: () => provider.open(), }); @@ -344,7 +481,7 @@ class FakeProvider { private receive(socket: FakeSocket, wire: Uint8Array): void { const message = parseCultNetMessage(decode(wire)); switch (message.schemaVersion) { - case "cultmesh.session_open.v1": + case "cultmesh.session_open.v2": this.acceptSession(socket, message); return; case "cultnet.database_subscribe.v0": @@ -363,13 +500,16 @@ class FakeProvider { private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void { socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v1", + schemaVersion: "cultmesh.session_accepted.v2", messageId: message.messageId, accepted: true, verseId: message.verseId, authorityRuntimeId: this.#runtimeId, protocolId: message.protocolId, routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", }); } @@ -501,20 +641,35 @@ function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket { let socket: FakeSocket; socket = new FakeSocket(wire => { const message = parseCultNetMessage(decode(wire)); - if (message.schemaVersion !== "cultmesh.session_open.v1") return; + if (message.schemaVersion !== "cultmesh.session_open.v2") return; socket.deliver({ - schemaVersion: "cultmesh.session_accepted.v1", + schemaVersion: "cultmesh.session_accepted.v2", messageId: message.messageId, accepted: true, verseId: route.verseId, authorityRuntimeId: route.authorityRuntimeId, protocolId: message.protocolId, routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: "", + providerSignature: "", }); }, () => undefined); return socket; } +function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute { + return { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation, + }; +} + class FakeSocket implements CultMeshBrowserSocket { binaryType: BinaryType = "arraybuffer"; readyState = 0; @@ -555,6 +710,116 @@ class FakeSocket implements CultMeshBrowserSocket { } } +async function createSignedRoute(endpoint: string): Promise<{ + route: CultMeshBrowserRoute; + odinPublic: CultMeshBrowserP256PublicKey; + providerPrivate: CryptoKey; + expiresAt: number; +}> { + const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]); + const odinPublic = await exportPublic("odin-root-1", odin.publicKey); + const providerPublic = await exportPublic("provider-1", provider.publicKey); + const issuedAt = Date.now() - 1_000; + const expiresAt = Date.now() + 60_000; + const unsigned: CultMeshBrowserRoute = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "signed-generation-1", + certificate: { + providerKey: providerPublic, + odinKeyId: odinPublic.keyId, + issuedAtUnixMilliseconds: issuedAt, + expiresAtUnixMilliseconds: expiresAt, + signature: "", + }, + }; + const signature = await crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + odin.privateKey, + testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer, + ); + return { + route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } }, + odinPublic, + providerPrivate: provider.privateKey, + expiresAt, + }; +} + +function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket { + let socket: FakeSocket; + socket = new FakeSocket(wire => { + const message = parseCultNetMessage(decode(wire)); + if (message.schemaVersion !== "cultmesh.session_open.v2") return; + void crypto.subtle.sign( + { name: "ECDSA", hash: "SHA-256" }, + providerPrivate, + testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer, + ).then(signature => socket.deliver({ + schemaVersion: "cultmesh.session_accepted.v2", + messageId: message.messageId, + accepted: true, + verseId: message.verseId, + authorityRuntimeId: message.authorityRuntimeId, + protocolId: message.protocolId, + routeGeneration: message.routeGeneration, + clientNonce: message.clientNonce, + providerKeyId: route.certificate!.providerKey.keyId, + providerSignature: toBase64(new Uint8Array(signature)), + })); + }, () => undefined); + return socket; +} + +async function exportPublic(keyId: string, key: CryptoKey): Promise { + const jwk = await crypto.subtle.exportKey("jwk", key); + return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) }; +} + +function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array { + const certificate = route.certificate!; + return testCanonical( + "gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint, + [...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation, + certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y, + certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds), + ); +} + +function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array { + return testCanonical( + "gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId, + message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration, + ); +} + +function testCanonical(...values: string[]): Uint8Array { + const chunks = values.map(value => new TextEncoder().encode(value)); + const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0)); + const view = new DataView(bytes.buffer); + let offset = 0; + for (const chunk of chunks) { + view.setUint32(offset, chunk.length, false); + offset += 4; + bytes.set(chunk, offset); + offset += chunk.length; + } + return bytes; +} + +function toBase64(bytes: Uint8Array): string { + return Buffer.from(bytes).toString("base64"); +} + +function urlBase64ToBase64(value: string): string { + return Buffer.from(value, "base64url").toString("base64"); +} + function bytesToBase64(bytes: Uint8Array): string { let binary = ""; for (const byte of bytes) binary += String.fromCharCode(byte); diff --git a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json index 55a2f742..aca1ff2b 100644 --- a/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json +++ b/packages/cultnet-ts/contracts/cultmesh.session-accepted.schema.json @@ -3,16 +3,19 @@ "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json", "title": "CultMesh Session Accepted", "type": "object", - "required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration"], + "required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"], "additionalProperties": false, "properties": { - "schemaVersion": { "const": "cultmesh.session_accepted.v1" }, + "schemaVersion": { "const": "cultmesh.session_accepted.v2" }, "messageId": { "type": "string", "minLength": 1 }, "accepted": { "type": "boolean" }, "verseId": { "type": "string", "minLength": 1 }, "authorityRuntimeId": { "type": "string", "minLength": 1 }, "protocolId": { "type": "string", "minLength": 1 }, "routeGeneration": { "type": "string" }, + "clientNonce": { "type": "string" }, + "providerKeyId": { "type": "string" }, + "providerSignature": { "type": "string" }, "error": { "type": "string", "minLength": 1 } } } diff --git a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json index 14d945ed..937f3e28 100644 --- a/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json +++ b/packages/cultnet-ts/contracts/cultmesh.session-open.schema.json @@ -3,15 +3,16 @@ "$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json", "title": "CultMesh Session Open", "type": "object", - "required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration"], + "required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"], "additionalProperties": false, "properties": { - "schemaVersion": { "const": "cultmesh.session_open.v1" }, + "schemaVersion": { "const": "cultmesh.session_open.v2" }, "messageId": { "type": "string", "minLength": 1 }, "sourceRuntimeId": { "type": "string", "minLength": 1 }, "verseId": { "type": "string", "minLength": 1 }, "authorityRuntimeId": { "type": "string", "minLength": 1 }, "protocolId": { "type": "string", "minLength": 1 }, - "routeGeneration": { "type": "string" } + "routeGeneration": { "type": "string" }, + "clientNonce": { "type": "string", "minLength": 1 } } } diff --git a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json index 6e7551d5..f23ef5f7 100644 --- a/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json +++ b/packages/cultnet-ts/contracts/cultmesh.verse-catalog-response.schema.json @@ -27,7 +27,22 @@ "endpoint": { "type": "string", "minLength": 1 }, "protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true }, "priority": { "type": "integer", "minimum": 0 }, - "generation": { "type": "string", "minLength": 1 } + "generation": { "type": "string", "minLength": 1 }, + "certificate": { "$ref": "#/$defs/routeCertificate" } + } + }, + "routeCertificate": { + "type": "object", + "required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"], + "additionalProperties": false, + "properties": { + "providerKeyId": { "type": "string", "minLength": 1 }, + "providerPublicKeyX": { "type": "string", "minLength": 1 }, + "providerPublicKeyY": { "type": "string", "minLength": 1 }, + "odinKeyId": { "type": "string", "minLength": 1 }, + "issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 }, + "expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 }, + "signature": { "type": "string", "minLength": 1 } } }, "verse": { diff --git a/packages/cultnet-ts/src/contracts.ts b/packages/cultnet-ts/src/contracts.ts index 2d86ad27..c71cdcc1 100644 --- a/packages/cultnet-ts/src/contracts.ts +++ b/packages/cultnet-ts/src/contracts.ts @@ -62,8 +62,8 @@ export type CultNetSchemaVersion = | "cultnet.operation_response.v0" | "cultmesh.verse_catalog_request.v0" | "cultmesh.verse_catalog_response.v0" - | "cultmesh.session_open.v1" - | "cultmesh.session_accepted.v1"; + | "cultmesh.session_open.v2" + | "cultmesh.session_accepted.v2"; export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract"; export type CultNetRawPayloadEncoding = "messagepack"; @@ -365,26 +365,41 @@ export interface CultMeshAuthorityRouteMessage { protocolIds: string[]; priority: number; generation: string; + certificate?: CultMeshRouteCertificateMessage; +} + +export interface CultMeshRouteCertificateMessage { + providerKeyId: string; + providerPublicKeyX: string; + providerPublicKeyY: string; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; } export interface CultMeshSessionOpenMessage { - schemaVersion: "cultmesh.session_open.v1"; + schemaVersion: "cultmesh.session_open.v2"; messageId: string; sourceRuntimeId: string; verseId: string; authorityRuntimeId: string; protocolId: string; routeGeneration: string; + clientNonce: string; } export interface CultMeshSessionAcceptedMessage { - schemaVersion: "cultmesh.session_accepted.v1"; + schemaVersion: "cultmesh.session_accepted.v2"; messageId: string; accepted: boolean; verseId: string; authorityRuntimeId: string; protocolId: string; routeGeneration: string; + clientNonce: string; + providerKeyId: string; + providerSignature: string; error?: string; } @@ -1236,7 +1251,7 @@ function normalizeCultNetOptionalNulls( } } return; - case "cultmesh.session_accepted.v1": + case "cultmesh.session_accepted.v2": stripNullProperties(candidate, ["error"]); return; case "cultnet.schema_catalog_request.v0": diff --git a/packages/cultnet-ts/test/cultnet.test.ts b/packages/cultnet-ts/test/cultnet.test.ts index f0f01b29..8d96c69a 100644 --- a/packages/cultnet-ts/test/cultnet.test.ts +++ b/packages/cultnet-ts/test/cultnet.test.ts @@ -97,15 +97,18 @@ test("CultNet validates the canonical CultMesh Verse catalog messages", () => { assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1"); const session = parseCultNetMessage({ - schemaVersion: "cultmesh.session_accepted.v1", + schemaVersion: "cultmesh.session_accepted.v2", messageId: "session-1", accepted: true, verseId: "sample.counter", authorityRuntimeId: "sample.counter-provider", protocolId: "cultmesh.documents.v1", routeGeneration: "provider-route-1", + clientNonce: "bm9uY2U=", + providerKeyId: "provider-key-1", + providerSignature: "c2lnbmF0dXJl", }); - assert.equal(session.schemaVersion, "cultmesh.session_accepted.v1"); + assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2"); assert.throws(() => parseCultNetMessage({ ...response, diff --git a/samples/eve-browser-network/Program.cs b/samples/eve-browser-network/Program.cs index db92d3c1..bfadd7db 100644 --- a/samples/eve-browser-network/Program.cs +++ b/samples/eve-browser-network/Program.cs @@ -198,6 +198,10 @@ static async Task RunHeadlessAsync(Args arguments) CreateClient = CreateClient, TransportVersion = arguments.TransportVersion }, + Sessions = new CultMeshSessionManagerOptions + { + Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment) + }, Connectors = [ new CultMeshUriSchemaTransportConnector( diff --git a/samples/eve-browser-network/browser/main.ts b/samples/eve-browser-network/browser/main.ts index 3b0e28f9..8251a0a3 100644 --- a/samples/eve-browser-network/browser/main.ts +++ b/samples/eve-browser-network/browser/main.ts @@ -32,6 +32,7 @@ try { verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", runtimeId: "sample.chromium", + trust: { mode: "local-development" }, rendezvous: new CultMeshBrowserOdinRendezvous({ endpoints: [odinEndpoint], runtimeId: "sample.chromium.discovery", diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 index d6f4a224..fa0d12bd 100644 --- a/scripts/pack-nuget.ps1 +++ b/scripts/pack-nuget.ps1 @@ -34,7 +34,7 @@ foreach ($project in $projects) { "--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632" ) if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) { - $packArguments += "-p:Version=$PackageVersion" + $packArguments += "-p:CultLibPackageVersion=$PackageVersion" } & dotnet @packArguments if ($LASTEXITCODE -ne 0) { diff --git a/src/GameCult.Mesh/CultMeshAuthorityProof.cs b/src/GameCult.Mesh/CultMeshAuthorityProof.cs new file mode 100644 index 00000000..d4b2d0c8 --- /dev/null +++ b/src/GameCult.Mesh/CultMeshAuthorityProof.cs @@ -0,0 +1,349 @@ +using System; +using System.Buffers.Binary; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Security.Cryptography; +using System.Text; +using GameCult.Networking; + +namespace GameCult.Mesh +{ + public enum CultMeshAuthorityTrustMode + { + AuthenticatedRemote, + LocalDevelopment + } + + public sealed class CultMeshEcdsaP256PublicKey + { + public CultMeshEcdsaP256PublicKey(string keyId, string x, string y) + { + KeyId = Require(keyId, nameof(keyId)); + X = RequireCoordinate(x, nameof(x)); + Y = RequireCoordinate(y, nameof(y)); + } + + public string KeyId { get; } + public string X { get; } + public string Y { get; } + + internal ECDsa CreateVerifier() + { + var verifier = ECDsa.Create(); + verifier.ImportParameters(new ECParameters + { + Curve = ECCurve.NamedCurves.nistP256, + Q = new ECPoint { X = Convert.FromBase64String(X), Y = Convert.FromBase64String(Y) } + }); + return verifier; + } + + public static CultMeshEcdsaP256PublicKey From(string keyId, ECDsa key) + { + if (key == null) throw new ArgumentNullException(nameof(key)); + var parameters = key.ExportParameters(false); + return new CultMeshEcdsaP256PublicKey( + keyId, + Convert.ToBase64String(parameters.Q.X ?? throw new InvalidOperationException("P-256 key has no X coordinate.")), + Convert.ToBase64String(parameters.Q.Y ?? throw new InvalidOperationException("P-256 key has no Y coordinate."))); + } + + private static string RequireCoordinate(string value, string name) + { + var required = Require(value, name); + byte[] bytes; + try { bytes = Convert.FromBase64String(required); } + catch (FormatException error) { throw new ArgumentException("P-256 coordinates must be base64.", name, error); } + if (bytes.Length != 32) throw new ArgumentException("P-256 coordinates must contain 32 bytes.", name); + return required; + } + + private static string Require(string value, string name) => + string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", name) : value.Trim(); + } + + public sealed class CultMeshRouteCertificate + { + public CultMeshRouteCertificate( + CultMeshEcdsaP256PublicKey providerKey, + string odinKeyId, + long issuedAtUnixMilliseconds, + long expiresAtUnixMilliseconds, + string signature) + { + ProviderKey = providerKey ?? throw new ArgumentNullException(nameof(providerKey)); + OdinKeyId = string.IsNullOrWhiteSpace(odinKeyId) + ? throw new ArgumentException("Odin key identity is required.", nameof(odinKeyId)) + : odinKeyId.Trim(); + if (issuedAtUnixMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(issuedAtUnixMilliseconds)); + if (expiresAtUnixMilliseconds <= issuedAtUnixMilliseconds) throw new ArgumentOutOfRangeException(nameof(expiresAtUnixMilliseconds)); + IssuedAtUnixMilliseconds = issuedAtUnixMilliseconds; + ExpiresAtUnixMilliseconds = expiresAtUnixMilliseconds; + Signature = signature?.Trim() ?? string.Empty; + } + + public CultMeshEcdsaP256PublicKey ProviderKey { get; } + public string OdinKeyId { get; } + public long IssuedAtUnixMilliseconds { get; } + public long ExpiresAtUnixMilliseconds { get; } + public string Signature { get; } + } + + public sealed class CultMeshAuthorityTrustPolicy + { + private readonly Dictionary _odinRoots; + + public CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode mode, + IEnumerable? odinRoots = null) + { + Mode = mode; + _odinRoots = (odinRoots ?? Array.Empty()) + .ToDictionary(key => key.KeyId, StringComparer.Ordinal); + } + + public CultMeshAuthorityTrustMode Mode { get; } + public IReadOnlyCollection OdinRoots => _odinRoots.Values; + + /// Fails closed unless the route is valid under this consumer-owned policy. + public void Validate(string verseId, CultMeshAuthorityRoute route, DateTimeOffset now) => + Verify(verseId, route, now); + + internal CultMeshVerifiedAuthorityRoute Verify( + string verseId, + CultMeshAuthorityRoute route, + DateTimeOffset now) + { + if (route == null) throw new ArgumentNullException(nameof(route)); + var certificate = route.Certificate; + if (certificate == null || string.IsNullOrWhiteSpace(certificate.Signature)) + { + if (Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint)) + return CultMeshVerifiedAuthorityRoute.Local(route); + throw Failure(route, "Remote CultMesh routes require an Odin-signed authority certificate."); + } + if (!IsProtected(route.Endpoint) && + !(Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint))) + throw Failure(route, "Authenticated remote CultMesh routes require TLS or QUIC channel protection."); + if (!_odinRoots.TryGetValue(certificate.OdinKeyId, out var root)) + throw Failure(route, $"Odin key '{certificate.OdinKeyId}' is not trusted by this consumer."); + var nowMs = now.ToUnixTimeMilliseconds(); + if (nowMs < certificate.IssuedAtUnixMilliseconds || nowMs >= certificate.ExpiresAtUnixMilliseconds) + throw Failure(route, "The Odin route certificate is not currently valid."); + byte[] signature; + try { signature = Convert.FromBase64String(certificate.Signature); } + catch (FormatException error) { throw Failure(route, "The Odin route signature is not base64.", error); } + if (signature.Length != 64) throw Failure(route, "The Odin route signature is not IEEE P1363 P-256."); + using var verifier = root.CreateVerifier(); + if (!verifier.VerifyData( + CultMeshAuthorityProof.CanonicalRoute(verseId, route), + signature, + HashAlgorithmName.SHA256)) + throw Failure(route, "The Odin route certificate signature is invalid."); + return CultMeshVerifiedAuthorityRoute.Authenticated(route, certificate.ProviderKey); + } + + private static bool IsLoopback(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false; + return uri.IsLoopback || + string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Host, "127.0.0.1", StringComparison.Ordinal) || + string.Equals(uri.Host, "::1", StringComparison.Ordinal); + } + + private static bool IsProtected(string endpoint) + { + if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false; + return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase) || + string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) || + uri.Scheme.IndexOf("quic", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static CultMeshSessionException Failure(CultMeshAuthorityRoute route, string message, Exception? inner = null) => + new CultMeshSessionException(new CultMeshSessionFailure( + CultMeshSessionFailureReason.Authentication, + message, + route.Endpoint), inner); + } + + internal sealed class CultMeshVerifiedAuthorityRoute + { + private CultMeshVerifiedAuthorityRoute( + CultMeshAuthorityRoute route, + CultMeshEcdsaP256PublicKey? providerKey, + bool localDevelopment) + { + Route = route; + ProviderKey = providerKey; + IsLocalDevelopment = localDevelopment; + } + + public CultMeshAuthorityRoute Route { get; } + public CultMeshEcdsaP256PublicKey? ProviderKey { get; } + public bool IsLocalDevelopment { get; } + public static CultMeshVerifiedAuthorityRoute Authenticated(CultMeshAuthorityRoute route, CultMeshEcdsaP256PublicKey key) => new(route, key, false); + public static CultMeshVerifiedAuthorityRoute Local(CultMeshAuthorityRoute route) => new(route, null, true); + } + + public sealed class CultMeshSessionProofSigner + { + private readonly ECDsa _key; + + public CultMeshSessionProofSigner(CultMeshAuthorityRoute route, ECDsa providerPrivateKey) + { + Route = route ?? throw new ArgumentNullException(nameof(route)); + _key = providerPrivateKey ?? throw new ArgumentNullException(nameof(providerPrivateKey)); + var certificate = route.Certificate ?? throw new ArgumentException("A session proof signer requires a certified route.", nameof(route)); + ProviderKeyId = certificate.ProviderKey.KeyId; + var exported = CultMeshEcdsaP256PublicKey.From(ProviderKeyId, _key); + if (!string.Equals(exported.X, certificate.ProviderKey.X, StringComparison.Ordinal) || + !string.Equals(exported.Y, certificate.ProviderKey.Y, StringComparison.Ordinal)) + throw new ArgumentException("Provider private key does not match the certified route key.", nameof(providerPrivateKey)); + } + + public CultMeshAuthorityRoute Route { get; } + public string ProviderKeyId { get; } + + public string Sign(CultMeshSessionOpenMessage request) + { + var bytes = CultMeshAuthorityProof.CanonicalSession(request, Route.Endpoint); + return Convert.ToBase64String(CultMeshAuthorityProof.SignP256(_key, bytes)); + } + } + + public static class CultMeshAuthorityProof + { + private const string RouteDomain = "gamecult.cultmesh.route-certificate.v1"; + private const string SessionDomain = "gamecult.cultmesh.session-proof.v1"; + + public static CultMeshAuthorityRoute CreateSignedRoute( + string verseId, + string authorityRuntimeId, + string endpoint, + IEnumerable protocolIds, + int priority, + string generation, + CultMeshEcdsaP256PublicKey providerKey, + string odinKeyId, + DateTimeOffset issuedAt, + DateTimeOffset expiresAt, + ECDsa odinPrivateKey) + { + if (odinPrivateKey == null) throw new ArgumentNullException(nameof(odinPrivateKey)); + var unsignedCertificate = new CultMeshRouteCertificate( + providerKey, + odinKeyId, + issuedAt.ToUnixTimeMilliseconds(), + expiresAt.ToUnixTimeMilliseconds(), + string.Empty); + var unsignedRoute = new CultMeshAuthorityRoute( + authorityRuntimeId, endpoint, protocolIds, priority, generation, unsignedCertificate); + var signature = Convert.ToBase64String(SignP256( + odinPrivateKey, + CanonicalRoute(verseId, unsignedRoute))); + return new CultMeshAuthorityRoute( + authorityRuntimeId, + endpoint, + protocolIds, + priority, + generation, + new CultMeshRouteCertificate( + providerKey, + odinKeyId, + issuedAt.ToUnixTimeMilliseconds(), + expiresAt.ToUnixTimeMilliseconds(), + signature)); + } + + /// Verifies both the Odin route certificate and provider nonce proof. + public static bool VerifySessionProof( + CultMeshSessionOpenMessage request, + CultMeshSessionAcceptedMessage response, + string verseId, + CultMeshAuthorityRoute route, + CultMeshAuthorityTrustPolicy trust, + DateTimeOffset now) + { + if (request == null) throw new ArgumentNullException(nameof(request)); + if (response == null) throw new ArgumentNullException(nameof(response)); + if (trust == null) throw new ArgumentNullException(nameof(trust)); + return VerifySession(request, response, trust.Verify(verseId, route, now)); + } + + internal static byte[] CanonicalRoute(string verseId, CultMeshAuthorityRoute route) + { + var certificate = route.Certificate ?? throw new InvalidOperationException("Route certificate is required."); + return Canonical( + RouteDomain, + verseId, + route.AuthorityRuntimeId, + route.Endpoint, + string.Join("\u001f", route.ProtocolIds.OrderBy(value => value, StringComparer.Ordinal)), + route.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture), + route.Generation, + certificate.ProviderKey.KeyId, + certificate.ProviderKey.X, + certificate.ProviderKey.Y, + certificate.OdinKeyId, + certificate.IssuedAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture), + certificate.ExpiresAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture)); + } + + internal static byte[] CanonicalSession(CultMeshSessionOpenMessage request, string endpoint) => Canonical( + SessionDomain, + request.ClientNonce ?? string.Empty, + request.MessageId ?? string.Empty, + request.SourceRuntimeId ?? string.Empty, + request.VerseId ?? string.Empty, + request.AuthorityRuntimeId ?? string.Empty, + request.ProtocolId ?? string.Empty, + endpoint ?? string.Empty, + request.RouteGeneration ?? string.Empty); + + internal static bool VerifySession( + CultMeshSessionOpenMessage request, + CultMeshSessionAcceptedMessage response, + CultMeshVerifiedAuthorityRoute route) + { + if (route.IsLocalDevelopment) return true; + if (route.ProviderKey == null || + !string.Equals(response.ProviderKeyId, route.ProviderKey.KeyId, StringComparison.Ordinal) || + !string.Equals(response.ClientNonce, request.ClientNonce, StringComparison.Ordinal) || + string.IsNullOrWhiteSpace(response.ProviderSignature)) + return false; + byte[] signature; + try { signature = Convert.FromBase64String(response.ProviderSignature); } + catch (FormatException) { return false; } + if (signature.Length != 64) return false; + using var verifier = route.ProviderKey.CreateVerifier(); + return verifier.VerifyData( + CanonicalSession(request, route.Route.Endpoint), + signature, + HashAlgorithmName.SHA256); + } + + internal static byte[] SignP256(ECDsa key, byte[] payload) + { + var signature = key.SignData(payload, HashAlgorithmName.SHA256); + if (signature.Length != 64) + throw new CryptographicException("ECDSA provider did not emit IEEE P1363 P-256 signature bytes."); + return signature; + } + + private static byte[] Canonical(params string[] values) + { + using var stream = new MemoryStream(); + var length = new byte[4]; + foreach (var value in values) + { + var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty); + BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length); + stream.Write(length, 0, length.Length); + stream.Write(bytes, 0, bytes.Length); + } + return stream.ToArray(); + } + } +} diff --git a/src/GameCult.Mesh/CultMeshContentSessions.cs b/src/GameCult.Mesh/CultMeshContentSessions.cs index 4c562450..890dbfa8 100644 --- a/src/GameCult.Mesh/CultMeshContentSessions.cs +++ b/src/GameCult.Mesh/CultMeshContentSessions.cs @@ -136,7 +136,9 @@ namespace GameCult.Mesh "cultmesh-content-client", target, CultMeshProtocols.Content, - candidate.Generation, + candidate, + null, + _options.Clock.UtcNow, _options.ResponseTimeout, cancellationToken).ConfigureAwait(false); return new LegacyRudpContentTransport( diff --git a/src/GameCult.Mesh/CultMeshSessionIdentity.cs b/src/GameCult.Mesh/CultMeshSessionIdentity.cs index bb17a3d8..43a49162 100644 --- a/src/GameCult.Mesh/CultMeshSessionIdentity.cs +++ b/src/GameCult.Mesh/CultMeshSessionIdentity.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using GameCult.Networking; @@ -68,6 +69,7 @@ namespace GameCult.Mesh private readonly HashSet _verseIds; private readonly HashSet _protocolIds; private readonly HashSet _routeGenerations; + private readonly Dictionary _proofSigners; private readonly Func _handler; private bool _disposed; @@ -76,13 +78,16 @@ namespace GameCult.Mesh string authorityRuntimeId, IEnumerable verseIds, IEnumerable protocolIds, - IEnumerable routeGenerations) + IEnumerable routeGenerations, + IEnumerable? proofSigners = null) { _server = server ?? throw new ArgumentNullException(nameof(server)); _authorityRuntimeId = Require(authorityRuntimeId, nameof(authorityRuntimeId)); _verseIds = Clean(verseIds, nameof(verseIds)); _protocolIds = Clean(protocolIds, nameof(protocolIds)); _routeGenerations = Clean(routeGenerations, nameof(routeGenerations)); + _proofSigners = (proofSigners ?? Array.Empty()) + .ToDictionary(signer => signer.Route.Generation, StringComparer.Ordinal); _handler = HandleAsync; _server.OnCultNet(_handler); } @@ -97,6 +102,7 @@ namespace GameCult.Mesh private Task HandleAsync(CultMeshSessionOpenMessage request, ICultNetSchemaServerPeer peer) { var error = Validate(request); + _proofSigners.TryGetValue(request.RouteGeneration ?? string.Empty, out var signer); peer.SendCultNet(new CultMeshSessionAcceptedMessage { MessageId = request.MessageId ?? string.Empty, @@ -105,6 +111,9 @@ namespace GameCult.Mesh AuthorityRuntimeId = _authorityRuntimeId, ProtocolId = request.ProtocolId ?? string.Empty, RouteGeneration = request.RouteGeneration ?? string.Empty, + ClientNonce = request.ClientNonce ?? string.Empty, + ProviderKeyId = error == null ? signer?.ProviderKeyId ?? string.Empty : string.Empty, + ProviderSignature = error == null && signer != null ? signer.Sign(request) : string.Empty, Error = error }); return Task.CompletedTask; @@ -114,6 +123,7 @@ namespace GameCult.Mesh { if (string.IsNullOrWhiteSpace(request.MessageId)) return "session-message-id-required"; if (string.IsNullOrWhiteSpace(request.SourceRuntimeId)) return "source-runtime-id-required"; + if (!TryNonce(request.ClientNonce)) return "client-nonce-invalid"; if (!string.Equals(request.AuthorityRuntimeId, _authorityRuntimeId, StringComparison.Ordinal)) return "target-runtime-mismatch"; if (!_verseIds.Contains(request.VerseId ?? string.Empty)) return "verse-not-served"; @@ -122,6 +132,13 @@ namespace GameCult.Mesh return null; } + private static bool TryNonce(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + try { return Convert.FromBase64String(value).Length == 32; } + catch (FormatException) { return false; } + } + private static HashSet Clean(IEnumerable values, string name) { if (values == null) throw new ArgumentNullException(name); @@ -145,26 +162,45 @@ namespace GameCult.Mesh string sourceRuntimeId, CultMeshSessionTarget target, CultMeshProtocolId protocol, - string routeGeneration, + CultMeshTransportCandidate candidate, + CultMeshAuthorityTrustPolicy? trust, + DateTimeOffset now, TimeSpan timeout, CancellationToken cancellationToken = default) { if (client == null) throw new ArgumentNullException(nameof(client)); + if (candidate == null) throw new ArgumentNullException(nameof(candidate)); + var route = candidate.AuthorityRoute ?? new CultMeshAuthorityRoute( + target.AuthorityRuntimeId, + candidate.Endpoint, + new[] { protocol.Value }, + candidate.Priority, + candidate.Generation); + var verifiedRoute = candidate.VerifiedAuthority ?? + (trust ?? throw new CultMeshSessionException(new CultMeshSessionFailure( + CultMeshSessionFailureReason.Authentication, + "No consumer trust policy verified this CultMesh route.", + candidate.Endpoint))).Verify(target.VerseId, route, now); if (client is ICultMeshVerifiedSchemaClient verified) { if (!verified.IsVerifiedFor( target.VerseId, target.AuthorityRuntimeId, protocol.Value, - routeGeneration ?? string.Empty)) + candidate.Generation)) { throw new CultMeshSessionException(new CultMeshSessionFailure( CultMeshSessionFailureReason.Authority, $"Native transport identity did not match CultMesh target '{target}' for '{protocol}'.")); } - return; + // Native identity is sufficient only for an explicitly selected + // loopback development route. Remote schema sessions still prove + // possession of the provider key certified by Odin. + if (verifiedRoute.IsLocalDevelopment) return; } var messageId = Guid.NewGuid().ToString("N"); + var nonce = new byte[32]; + using (var random = RandomNumberGenerator.Create()) random.GetBytes(nonce); var completion = new TaskCompletionSource( TaskCreationOptions.RunContinuationsAsynchronously); client.OnCultNet(response => @@ -172,15 +208,17 @@ namespace GameCult.Mesh if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal)) completion.TrySetResult(response); }); - client.SendCultNet(new CultMeshSessionOpenMessage + var request = new CultMeshSessionOpenMessage { MessageId = messageId, SourceRuntimeId = sourceRuntimeId, VerseId = target.VerseId, AuthorityRuntimeId = target.AuthorityRuntimeId, ProtocolId = protocol.Value, - RouteGeneration = routeGeneration ?? string.Empty - }); + RouteGeneration = candidate.Generation, + ClientNonce = Convert.ToBase64String(nonce) + }; + client.SendCultNet(request); using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); deadline.CancelAfter(timeout); @@ -201,7 +239,8 @@ namespace GameCult.Mesh !string.Equals(accepted.VerseId, target.VerseId, StringComparison.Ordinal) || !string.Equals(accepted.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) || !string.Equals(accepted.ProtocolId, protocol.Value, StringComparison.Ordinal) || - !string.Equals(accepted.RouteGeneration, routeGeneration ?? string.Empty, StringComparison.Ordinal)) + !string.Equals(accepted.RouteGeneration, candidate.Generation, StringComparison.Ordinal) || + !CultMeshAuthorityProof.VerifySession(request, accepted, verifiedRoute)) { throw new CultMeshSessionException(new CultMeshSessionFailure( CultMeshSessionFailureReason.Authority, diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs index 7db6f06b..b0dace01 100644 --- a/src/GameCult.Mesh/CultMeshSessions.cs +++ b/src/GameCult.Mesh/CultMeshSessions.cs @@ -86,19 +86,28 @@ namespace GameCult.Mesh CultMeshTransportPathKind pathKind = CultMeshTransportPathKind.Direct, int priority = 0, string authorityRuntimeId = "", - string generation = "") + string generation = "", + CultMeshAuthorityRoute? authorityRoute = null) { Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint; PathKind = pathKind; Priority = priority; AuthorityRuntimeId = authorityRuntimeId?.Trim() ?? string.Empty; Generation = generation?.Trim() ?? string.Empty; + AuthorityRoute = authorityRoute; } public string Endpoint { get; } public CultMeshTransportPathKind PathKind { get; } public int Priority { get; } public string AuthorityRuntimeId { get; } public string Generation { get; } + public CultMeshAuthorityRoute? AuthorityRoute { get; } + internal CultMeshVerifiedAuthorityRoute? VerifiedAuthority { get; private set; } + internal CultMeshTransportCandidate WithVerifiedAuthority(CultMeshVerifiedAuthorityRoute verified) + { + VerifiedAuthority = verified ?? throw new ArgumentNullException(nameof(verified)); + return this; + } } public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline } @@ -534,6 +543,9 @@ namespace GameCult.Mesh public int MaxRacedCandidates { get; set; } = 2; public string RuntimeId { get; set; } = "cultmesh-client"; public TimeSpan IdentityHandshakeTimeout { get; set; } = TimeSpan.FromSeconds(5); + /// Gets or sets the consumer-owned trust roots and explicit local-development policy. + public CultMeshAuthorityTrustPolicy Trust { get; set; } = new( + CultMeshAuthorityTrustMode.AuthenticatedRemote); } public sealed class CultMeshSessionManager : IDisposable @@ -1039,7 +1051,9 @@ namespace GameCult.Mesh _options.RuntimeId, target, protocol, - route.Candidate.Generation, + route.Candidate, + _options.Trust, + _options.Clock.UtcNow, _options.IdentityHandshakeTimeout).ConfigureAwait(false); return new ConnectedPath(route.Candidate, client); } @@ -1065,7 +1079,7 @@ namespace GameCult.Mesh private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) => new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner); - private static CultMeshTransportCandidate[] BoundCandidates( + private CultMeshTransportCandidate[] BoundCandidates( CultMeshDiscoveryState discovery, CultMeshSessionTarget target, CultMeshProtocolId protocol) @@ -1080,10 +1094,15 @@ namespace GameCult.Mesh .GroupBy(route => route.Endpoint, StringComparer.Ordinal) .Select(group => group.OrderBy(route => route.Priority).First()) .Select(route => new CultMeshTransportCandidate( - route.Endpoint, - priority: route.Priority, - authorityRuntimeId: route.AuthorityRuntimeId, - generation: route.Generation)) + route.Endpoint, + priority: route.Priority, + authorityRuntimeId: route.AuthorityRuntimeId, + generation: route.Generation, + authorityRoute: route) + .WithVerifiedAuthority(_options.Trust.Verify( + target.VerseId, + route, + _options.Clock.UtcNow))) .OrderBy(candidate => candidate.Priority) .ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal) .ToArray(); diff --git a/src/GameCult.Mesh/CultMeshTcpContentTransport.cs b/src/GameCult.Mesh/CultMeshTcpContentTransport.cs index f47f08bd..dcb07feb 100644 --- a/src/GameCult.Mesh/CultMeshTcpContentTransport.cs +++ b/src/GameCult.Mesh/CultMeshTcpContentTransport.cs @@ -17,6 +17,7 @@ namespace GameCult.Mesh public string VerseId { get; set; } = string.Empty; public string AuthorityRuntimeId { get; set; } = string.Empty; public string RouteGeneration { get; set; } = string.Empty; + public CultMeshSessionProofSigner? ProofSigner { get; set; } internal void Validate() { @@ -344,7 +345,10 @@ namespace GameCult.Mesh var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false); var request = CultNetSchemaMessageSerialization.Deserialize(requestBytes) as CultMeshSessionOpenMessage ?? throw new InvalidDataException("TCP content session did not begin with an authority handshake."); - var accepted = string.Equals(request.VerseId, _options.VerseId, StringComparison.Ordinal) && + var accepted = !string.IsNullOrWhiteSpace(request.MessageId) && + !string.IsNullOrWhiteSpace(request.SourceRuntimeId) && + IsNonce(request.ClientNonce) && + string.Equals(request.VerseId, _options.VerseId, StringComparison.Ordinal) && string.Equals(request.AuthorityRuntimeId, _options.AuthorityRuntimeId, StringComparison.Ordinal) && string.Equals(request.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) && string.Equals(request.RouteGeneration, _options.RouteGeneration, StringComparison.Ordinal); @@ -356,6 +360,11 @@ namespace GameCult.Mesh AuthorityRuntimeId = _options.AuthorityRuntimeId, ProtocolId = CultMeshProtocols.Content.Value, RouteGeneration = _options.RouteGeneration, + ClientNonce = request.ClientNonce ?? string.Empty, + ProviderKeyId = accepted ? _options.ProofSigner?.ProviderKeyId ?? string.Empty : string.Empty, + ProviderSignature = accepted && _options.ProofSigner != null + ? _options.ProofSigner.Sign(request) + : string.Empty, Error = accepted ? null : "Content session target does not match this authority route." }; await CultMeshTcpContentWire.WriteFrameAsync( @@ -365,6 +374,13 @@ namespace GameCult.Mesh return accepted; } + private static bool IsNonce(string? value) + { + if (string.IsNullOrWhiteSpace(value)) return false; + try { return Convert.FromBase64String(value).Length == 32; } + catch (FormatException) { return false; } + } + private async Task SendChunkAsync( Stream stream, CultMeshContentChunkRequestMessage request, @@ -473,7 +489,8 @@ namespace GameCult.Mesh VerseId = target.VerseId, AuthorityRuntimeId = target.AuthorityRuntimeId, ProtocolId = CultMeshProtocols.Content.Value, - RouteGeneration = candidate.Generation + RouteGeneration = candidate.Generation, + ClientNonce = CreateNonce() }; await WriteFrameAsync(stream, CultNetSchemaMessageSerialization.Serialize(request), cancellationToken) .ConfigureAwait(false); @@ -485,13 +502,22 @@ namespace GameCult.Mesh !string.Equals(response.VerseId, target.VerseId, StringComparison.Ordinal) || !string.Equals(response.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) || !string.Equals(response.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) || - !string.Equals(response.RouteGeneration, candidate.Generation, StringComparison.Ordinal)) + !string.Equals(response.RouteGeneration, candidate.Generation, StringComparison.Ordinal) || + candidate.VerifiedAuthority == null || + !CultMeshAuthorityProof.VerifySession(request, response, candidate.VerifiedAuthority)) throw new CultMeshSessionException(new CultMeshSessionFailure( CultMeshSessionFailureReason.Authority, response.Error ?? "TCP content endpoint did not prove the selected authority route.", candidate.Endpoint)); } + private static string CreateNonce() + { + var nonce = new byte[32]; + using (var random = System.Security.Cryptography.RandomNumberGenerator.Create()) random.GetBytes(nonce); + return Convert.ToBase64String(nonce); + } + public static async Task ReadFrameAsync(Stream stream, CancellationToken cancellationToken) { var header = new byte[HeaderBytes]; diff --git a/src/GameCult.Mesh/CultMeshVerse.cs b/src/GameCult.Mesh/CultMeshVerse.cs index 55d6deeb..003dc161 100644 --- a/src/GameCult.Mesh/CultMeshVerse.cs +++ b/src/GameCult.Mesh/CultMeshVerse.cs @@ -102,7 +102,8 @@ namespace GameCult.Mesh string endpoint, IEnumerable? protocolIds = null, int priority = 0, - string? generation = null) + string? generation = null, + CultMeshRouteCertificate? certificate = null) { AuthorityRuntimeId = RequireNonEmpty(authorityRuntimeId, nameof(authorityRuntimeId)); Endpoint = RequireNonEmpty(endpoint, nameof(endpoint)); @@ -112,6 +113,7 @@ namespace GameCult.Mesh Generation = string.IsNullOrWhiteSpace(generation) ? AuthorityRuntimeId + "@" + Endpoint : generation!.Trim(); + Certificate = certificate; } public string AuthorityRuntimeId { get; } @@ -119,6 +121,8 @@ namespace GameCult.Mesh public IReadOnlyList ProtocolIds { get; } public int Priority { get; } public string Generation { get; } + /// Gets the optional Odin-signed binding from this route to its provider key. + public CultMeshRouteCertificate? Certificate { get; } public bool Supports(CultMeshProtocolId protocol) => protocol != null && diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs index 774a05f9..c4ee15c7 100644 --- a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs +++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs @@ -39,7 +39,17 @@ namespace GameCult.Mesh Endpoint = route.Endpoint, ProtocolIds = route.ProtocolIds.ToArray(), Priority = route.Priority, - Generation = route.Generation + Generation = route.Generation, + Certificate = route.Certificate == null ? null : new CultMeshRouteCertificateMessage + { + ProviderKeyId = route.Certificate.ProviderKey.KeyId, + ProviderPublicKeyX = route.Certificate.ProviderKey.X, + ProviderPublicKeyY = route.Certificate.ProviderKey.Y, + OdinKeyId = route.Certificate.OdinKeyId, + IssuedAtUnixMilliseconds = route.Certificate.IssuedAtUnixMilliseconds, + ExpiresAtUnixMilliseconds = route.Certificate.ExpiresAtUnixMilliseconds, + Signature = route.Certificate.Signature + } }).ToArray(), ParentVerseId = verse.ParentVerseId, Description = verse.Description @@ -63,7 +73,16 @@ namespace GameCult.Mesh route.Endpoint, route.ProtocolIds, route.Priority, - route.Generation)).ToArray(); + route.Generation, + route.Certificate == null ? null : new CultMeshRouteCertificate( + new CultMeshEcdsaP256PublicKey( + route.Certificate.ProviderKeyId, + route.Certificate.ProviderPublicKeyX, + route.Certificate.ProviderPublicKeyY), + route.Certificate.OdinKeyId, + route.Certificate.IssuedAtUnixMilliseconds, + route.Certificate.ExpiresAtUnixMilliseconds, + route.Certificate.Signature))).ToArray(); return new CultMeshVerseDescriptor( message.VerseId, message.DisplayName, diff --git a/src/GameCult.Mesh/docs/browser-verse-transport.md b/src/GameCult.Mesh/docs/browser-verse-transport.md index ecec20ef..fcfde998 100644 --- a/src/GameCult.Mesh/docs/browser-verse-transport.md +++ b/src/GameCult.Mesh/docs/browser-verse-transport.md @@ -8,7 +8,8 @@ This is the implementation map for the browser Verse boundary. `samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint. `samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve lowerer, an independent C# headless observer, and a separate local Odin fixture -over authenticated binary CultNet WebSocket routes. It proves one canonical +over binary loopback CultNet WebSocket routes under an explicit +`local-development` trust policy. It proves one canonical command effect, idempotent retry, shared observation, provider restart on a new physical route, browser rediscovery, retained lease resubscription, and a second canonical command after reconnection. @@ -29,6 +30,10 @@ not a complete public Verse proof: `cultmesh.verse_catalog_request.v0` and `cultmesh.verse_catalog_response.v0`; application callbacks no longer need to smuggle provider URLs into the browser client; +- `CultMeshBrowserClient` owns the final authority gate: remote use requires a + consumer-pinned Odin P-256 root, an Odin-signed route certificate, `wss://`, + and provider possession proof over a fresh nonce. A catalog source cannot + bypass that gate by returning a route-shaped object; - Kotlin has a channel-aware WebSocket transport adapter, TypeScript has the browser client, and `GameCult.Networking.WebSockets` provides the bounded C# schema server host adapter; @@ -58,9 +63,9 @@ Owner: Inputs: -- an Odin-discovered WebSocket transport candidate; +- an Odin-discovered route certificate and consumer-configured Odin root; - stable Verse and provider identities; -- authenticated session evidence; +- provider key possession over a fresh client nonce; - requested schema ids and record keys; - typed operation invocations and idempotency keys; - canonical CultNet schema-v0 MessagePack messages. diff --git a/src/GameCult.Mesh/docs/getting-started/02-connect-by-identity.md b/src/GameCult.Mesh/docs/getting-started/02-connect-by-identity.md index ee505acb..28cc06ad 100644 --- a/src/GameCult.Mesh/docs/getting-started/02-connect-by-identity.md +++ b/src/GameCult.Mesh/docs/getting-started/02-connect-by-identity.md @@ -9,9 +9,10 @@ selection owner then resolves the provider runtime from Odin's descriptor before opening the session. Discovery owns the current physical routes, and the session manager owns connection reuse, path rotation, and transport failure state. Each Odin route binds that authority runtime to one endpoint, protocol set, and -generation. The client verifies the same tuple with the connected peer before -the session becomes online; runtime membership and endpoint reachability are -not independent routing evidence. +generation. For a remote Verse, Odin signs that exact tuple and the provider's +P-256 public key. The client pins an Odin root from its own configuration, then +requires the connected provider to sign a fresh client nonce. A route cannot +become usable by merely repeating the expected Verse and runtime strings. ```csharp using GameCult.Mesh; @@ -22,9 +23,22 @@ const string verseId = "sample.counter"; var target = new CultMeshSessionTarget(verseId, "sample.counter-daemon"); using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30)); var cancellationToken = timeout.Token; +var odinRoot = new CultMeshEcdsaP256PublicKey( + Environment.GetEnvironmentVariable("ODIN_ROOT_KEY_ID") + ?? throw new InvalidOperationException("Set ODIN_ROOT_KEY_ID."), + Environment.GetEnvironmentVariable("ODIN_ROOT_P256_X") + ?? throw new InvalidOperationException("Set ODIN_ROOT_P256_X."), + Environment.GetEnvironmentVariable("ODIN_ROOT_P256_Y") + ?? throw new InvalidOperationException("Set ODIN_ROOT_P256_Y.")); using var mesh = new CultMeshClient(new CultMeshClientOptions { - RendezvousEndpoints = new[] { odinEndpoint } + RendezvousEndpoints = new[] { odinEndpoint }, + Sessions = new CultMeshSessionManagerOptions + { + Trust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote, + new[] { odinRoot }) + } }); using var counterLease = await mesh.LeaseDocumentAsync( @@ -64,6 +78,21 @@ using var stateWatch = session.WatchState().Subscribe(state => Application code does not reconnect, sleep, rank endpoints, or replace the session. Those decisions belong to CultMesh. +For a deliberately local, moddable Verse, select the exception explicitly and +use only in-process or loopback routes: + +```csharp +Sessions = new CultMeshSessionManagerOptions +{ + Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment) +} +``` + +`LocalDevelopment` never accepts an unsigned non-loopback route. Verse names, +provider names, and Odin wire fields cannot turn this exception on. Remote +routes require a trusted Odin signature, provider nonce proof, certificate +validity, and a protected channel. + Fast session-management check (this uses controlled transports; it is not the network proof): @@ -71,7 +100,7 @@ network proof): dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj --filter FullyQualifiedName~CultMeshSessionManagerTests ``` -The real checkpoint in chapter 4 additionally advertises a better-priority +The local checkpoint in chapter 4 additionally advertises a better-priority endpoint for the wrong authority runtime. Both the C# and browser clients must ignore it, verify the intended peer, survive route replacement, and reject wrong-source operation responses. diff --git a/src/GameCult.Mesh/docs/transport-planes.md b/src/GameCult.Mesh/docs/transport-planes.md index bd42a071..fea2b69b 100644 --- a/src/GameCult.Mesh/docs/transport-planes.md +++ b/src/GameCult.Mesh/docs/transport-planes.md @@ -64,13 +64,20 @@ operation. transport diagnostics. None of these grant provider authority or become content identity. -Before a schema, content, or realtime session becomes online, the transport -must prove that the connected peer matches the selected Verse, authority -runtime, protocol, and route generation. TCP schema and content lanes use the -portable CultNet session-open handshake. QUIC uses the exact Odin-bound route -plus its TLS endpoint/certificate proof. A connector that returns bytes without -authority proof is rejected; racing every endpoint in a Verse descriptor is -not route selection. +Before a schema, content, or realtime session becomes online, consumer-owned +trust verifies an Odin-signed route certificate binding Verse, authority +runtime, protocol set, endpoint, generation, validity interval, and provider +P-256 key. Schema and content lanes then require a fresh client nonce signed by +that provider key. QUIC uses the signed endpoint plus its TLS +endpoint/certificate proof. A connector that returns bytes without this chain +is rejected; racing every endpoint in a Verse descriptor is not route +selection. Unsigned traffic exists only under an explicitly selected +`LocalDevelopment` policy and only for in-process or loopback routes. + +Route and session signatures use ECDSA P-256 with SHA-256 and fixed-width IEEE +P1363 `r || s` encoding, matching browser WebCrypto. Their canonical +transcripts are ordered UTF-8 fields prefixed by four-byte big-endian lengths; +serializer bytes are never signature authority. **Forbidden writers:** applications and renderers do not choose unadvertised endpoints, implement reconnect loops, or bypass verification. Schema messages @@ -94,7 +101,8 @@ new seam does not pretend that migration has already occurred. ## Security and release gate The current TCP control and content connectors are plaintext and therefore -restricted to local or otherwise trusted deployment. Adding TLS must not move +restricted to explicit loopback development. They cannot carry an authenticated +remote session. Adding TLS must not move discovery, authorization, content verification, or application semantics into the TCP adapter. diff --git a/src/GameCult.Networking/CultNetSchemaMessages.cs b/src/GameCult.Networking/CultNetSchemaMessages.cs index 2117693d..56370207 100644 --- a/src/GameCult.Networking/CultNetSchemaMessages.cs +++ b/src/GameCult.Networking/CultNetSchemaMessages.cs @@ -136,9 +136,9 @@ namespace GameCult.Networking /// public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0"; /// CultMesh session identity request contract identifier. - public const string SessionOpen = "cultmesh.session_open.v1"; + public const string SessionOpen = "cultmesh.session_open.v2"; /// CultMesh verified session identity response contract identifier. - public const string SessionAccepted = "cultmesh.session_accepted.v1"; + public const string SessionAccepted = "cultmesh.session_accepted.v2"; /// /// CultMesh peer exchange request contract identifier. /// @@ -1194,6 +1194,7 @@ namespace GameCult.Networking [Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty; [Key("protocolId")] public string ProtocolId { get; set; } = string.Empty; [Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty; + [Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty; } /// Accepts or rejects a CultMesh session with the server's actual identity. @@ -1207,6 +1208,9 @@ namespace GameCult.Networking [Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty; [Key("protocolId")] public string ProtocolId { get; set; } = string.Empty; [Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty; + [Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty; + [Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty; + [Key("providerSignature")] public string ProviderSignature { get; set; } = string.Empty; [Key("error")] public string? Error { get; set; } } @@ -1308,6 +1312,19 @@ namespace GameCult.Networking [Key("protocolIds")] public string[] ProtocolIds { get; set; } = Array.Empty(); [Key("priority")] public int Priority { get; set; } [Key("generation")] public string Generation { get; set; } = string.Empty; + [Key("certificate")] public CultMeshRouteCertificateMessage? Certificate { get; set; } + } + + [MessagePackObject] + public class CultMeshRouteCertificateMessage + { + [Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty; + [Key("providerPublicKeyX")] public string ProviderPublicKeyX { get; set; } = string.Empty; + [Key("providerPublicKeyY")] public string ProviderPublicKeyY { get; set; } = string.Empty; + [Key("odinKeyId")] public string OdinKeyId { get; set; } = string.Empty; + [Key("issuedAtUnixMilliseconds")] public long IssuedAtUnixMilliseconds { get; set; } + [Key("expiresAtUnixMilliseconds")] public long ExpiresAtUnixMilliseconds { get; set; } + [Key("signature")] public string Signature { get; set; } = string.Empty; } /// diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs new file mode 100644 index 00000000..88c7e803 --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs @@ -0,0 +1,241 @@ +using System; +using System.Collections.Generic; +using System.Security.Cryptography; +using System.Threading; +using System.Threading.Tasks; +using FluentAssertions; +using GameCult.Networking; +using NUnit.Framework; + +namespace GameCult.Mesh.Tests; + +public sealed class CultMeshAuthorityProofTests +{ + private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1_800_000_000_000); + + [Test] + public void SignedRouteBindsEveryAuthorityFieldToConsumerTrust() + { + using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var route = SignedRoute(odin, provider); + var trust = Trust(odin); + + trust.Validate("aetheria", route, Now); + var wireRoundTrip = new CultMeshVerseDescriptor( + "aetheria", + "Aetheria", + CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility("cultmesh.v0", "rules"), + authorityRoutes: new[] { route }) + .ToMessage() + .ToVerseDescriptor() + .AuthorityRoutes[0]; + trust.Validate("aetheria", wireRoundTrip, Now); + wireRoundTrip.Certificate!.ProviderKey.KeyId.Should().Be("provider-1"); + + Action mutatedEndpoint = () => trust.Validate("aetheria", new CultMeshAuthorityRoute( + route.AuthorityRuntimeId, + "wss://evil.example/mesh", + route.ProtocolIds, + route.Priority, + route.Generation, + route.Certificate), Now); + mutatedEndpoint.Should().Throw() + .Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authentication); + } + + [Test] + public void RemoteTrustRejectsUnknownExpiredAndUnsignedRoutes() + { + using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var route = SignedRoute(odin, provider); + + Action unknownRoot = () => Trust(stranger).Validate("aetheria", route, Now); + Action expired = () => Trust(odin).Validate("aetheria", route, Now.AddHours(2)); + Action unsigned = () => Trust(odin).Validate("aetheria", new CultMeshAuthorityRoute( + "aetheria-daemon", "wss://provider.example/mesh", + new[] { CultMeshProtocols.Documents.Value }, generation: "generation-1"), Now); + + unknownRoot.Should().Throw(); + expired.Should().Throw(); + unsigned.Should().Throw(); + } + + [Test] + public void UnsignedRouteRequiresExplicitLoopbackDevelopmentPolicy() + { + var local = new CultMeshAuthorityRoute( + "aetheria-daemon", "rudp://127.0.0.1:3076", + new[] { CultMeshProtocols.Documents.Value }, generation: "local"); + var remote = new CultMeshAuthorityRoute( + "aetheria-daemon", "rudp://192.0.2.10:3076", + new[] { CultMeshProtocols.Documents.Value }, generation: "remote"); + var policy = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment); + + policy.Validate("aetheria", local, Now); + Action remoteAttempt = () => policy.Validate("aetheria", remote, Now); + remoteAttempt.Should().Throw(); + } + + [Test] + public void ProviderProofRejectsCredentialFreeEchoAndNonceReplay() + { + using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var route = SignedRoute(odin, provider); + var trust = Trust(odin); + var signer = new CultMeshSessionProofSigner(route, provider); + var request = Request("nonce-a"); + var signed = Accepted(request, signer.Sign(request)); + + CultMeshAuthorityProof.VerifySessionProof(request, signed, "aetheria", route, trust, Now).Should().BeTrue(); + + var echo = Accepted(request, string.Empty); + CultMeshAuthorityProof.VerifySessionProof(request, echo, "aetheria", route, trust, Now).Should().BeFalse(); + + var replayedRequest = Request("nonce-b"); + CultMeshAuthorityProof.VerifySessionProof(replayedRequest, signed, "aetheria", route, trust, Now).Should().BeFalse(); + } + + [Test] + public async Task SessionManagerRejectsPeerThatOnlyEchoesCertifiedIdentityStrings() + { + using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var now = DateTimeOffset.UtcNow; + var route = CultMeshAuthorityProof.CreateSignedRoute( + "aetheria", "aetheria-daemon", "wss://provider.example/mesh", + new[] { CultMeshProtocols.Documents.Value }, 0, "generation-live", + CultMeshEcdsaP256PublicKey.From("provider-live", provider), "odin-live", + now.AddMinutes(-1), now.AddMinutes(5), odin); + var descriptor = new CultMeshVerseDescriptor( + "aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility("cultmesh.v0", "rules"), + authorityRoutes: new[] { route }); + using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) }); + using var manager = new CultMeshSessionManager( + discovery, + new[] { new EchoConnector() }, + new CultMeshSessionManagerOptions + { + Trust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote, + new[] { CultMeshEcdsaP256PublicKey.From("odin-live", odin) }) + }); + + Func connect = async () => await manager.ConnectAsync( + new CultMeshSessionTarget("aetheria", "aetheria-daemon"), + CultMeshProtocols.Documents); + + (await connect.Should().ThrowAsync()) + .Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority); + } + + private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) => + CultMeshAuthorityProof.CreateSignedRoute( + "aetheria", + "aetheria-daemon", + "wss://provider.example/mesh", + new[] { CultMeshProtocols.Documents.Value }, + 0, + "generation-1", + CultMeshEcdsaP256PublicKey.From("provider-1", provider), + "odin-root-1", + Now.AddMinutes(-1), + Now.AddHours(1), + odin); + + private static CultMeshAuthorityTrustPolicy Trust(ECDsa odin) => new( + CultMeshAuthorityTrustMode.AuthenticatedRemote, + new[] { CultMeshEcdsaP256PublicKey.From("odin-root-1", odin) }); + + private static GameCult.Networking.CultMeshSessionOpenMessage Request(string nonceLabel) + { + using var hash = SHA256.Create(); + return new GameCult.Networking.CultMeshSessionOpenMessage + { + MessageId = "message-1", + SourceRuntimeId = "browser-1", + VerseId = "aetheria", + AuthorityRuntimeId = "aetheria-daemon", + ProtocolId = CultMeshProtocols.Documents.Value, + RouteGeneration = "generation-1", + ClientNonce = Convert.ToBase64String(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(nonceLabel))) + }; + } + + private static GameCult.Networking.CultMeshSessionAcceptedMessage Accepted( + GameCult.Networking.CultMeshSessionOpenMessage request, + string signature) => new() + { + MessageId = request.MessageId, + Accepted = true, + VerseId = request.VerseId, + AuthorityRuntimeId = request.AuthorityRuntimeId, + ProtocolId = request.ProtocolId, + RouteGeneration = request.RouteGeneration, + ClientNonce = request.ClientNonce, + ProviderKeyId = "provider-1", + ProviderSignature = signature + }; + + private sealed class SignedRouteSource : ICultMeshLookupSource + { + private readonly CultMeshVerseDescriptor _descriptor; + public SignedRouteSource(CultMeshVerseDescriptor descriptor) => _descriptor = descriptor; + public string SourceId => "signed-odin"; + public Task> LookupAsync( + CultMeshDiscoveryQuery query, + CancellationToken cancellationToken = default) => + Task.FromResult>(new[] + { + new CultMeshDiscoveryObservation( + _descriptor, SourceId, DateTimeOffset.UtcNow, + DateTimeOffset.UtcNow.AddMinutes(1), CultMeshDiscoveryTrust.Signed) + }); + } + + private sealed class EchoConnector : ICultMeshTransportConnector + { + public string ConnectorId => "credential-free-echo"; + public int Priority => 0; + public bool CanConnect(CultMeshTransportCandidate candidate) => true; + public Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshProtocolId protocol, + CancellationToken cancellationToken = default) => + Task.FromResult(new EchoClient()); + } + + private sealed class EchoClient : ICultNetSchemaClient + { + private Action? _accepted; + public bool Connected => true; + public void Connect(string host, int port) { } + public void SendCultNet(T message) where T : ICultNetSchemaMessage + { + if (message is not CultMeshSessionOpenMessage request) return; + _accepted?.Invoke(new CultMeshSessionAcceptedMessage + { + MessageId = request.MessageId, + Accepted = true, + VerseId = request.VerseId, + AuthorityRuntimeId = request.AuthorityRuntimeId, + ProtocolId = request.ProtocolId, + RouteGeneration = request.RouteGeneration, + ClientNonce = request.ClientNonce, + ProviderKeyId = "provider-live", + ProviderSignature = string.Empty + }); + } + public void OnCultNet(Action callback) where T : ICultNetSchemaMessage + { + if (typeof(T) == typeof(CultMeshSessionAcceptedMessage)) + _accepted = response => callback((T)(object)response); + } + public void Dispose() { } + } +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs b/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs index 81ee15cc..803a055d 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshBodySessionTests.cs @@ -26,7 +26,7 @@ public sealed class CultMeshBodySessionTests var client = new LoopbackClient(server); var connector = new LoopbackConnector(client); using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource() }); - using var sessions = new CultMeshSessionManager(discovery, new[] { connector }); + using var sessions = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions); var provider = new CultMeshSessionBodyProvider( "aetheria.daemon", sessions, new CultMeshSessionTarget("aetheria", "aetheria.daemon")); @@ -73,7 +73,7 @@ public sealed class CultMeshBodySessionTests }); using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) }); using var sessions = new CultMeshSessionManager(discovery, - new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() }); + new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() }, CultMeshTestTrust.LocalSessions); var provider = new CultMeshSessionBodyProvider("aetheria.daemon", sessions, new CultMeshSessionTarget("aetheria", "aetheria.daemon"), new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(10) }); @@ -142,7 +142,7 @@ public sealed class CultMeshBodySessionTests private sealed class RouteSource : ICultMeshLookupSource { private readonly string _endpoint; - public RouteSource(string endpoint = "rudp://body.test:3076") => _endpoint = endpoint; + public RouteSource(string endpoint = "rudp://127.0.0.13:3076") => _endpoint = endpoint; public string SourceId => "test"; public Task> LookupAsync(CultMeshDiscoveryQuery query, CancellationToken cancellationToken = default) diff --git a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs index e603415e..dcabf6e6 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshClientTests.cs @@ -40,6 +40,7 @@ public sealed class CultMeshClientTests return new RendezvousClient(); } }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -48,7 +49,7 @@ public sealed class CultMeshClientTests first.Should().BeSameAs(second); first.Target.Should().Be(Target); - first.State.Path!.Endpoint.Should().Be("rudp://aetheria:3081"); + first.State.Path!.Endpoint.Should().Be("rudp://127.0.0.1:3081"); discoveryClients.Should().Be(1); connector.ConnectCount.Should().Be(1); } @@ -61,6 +62,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -82,6 +84,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -103,6 +106,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); using var lease = await mesh.LeaseDocumentAsync(Target, "surface:pilot"); @@ -125,6 +129,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -148,6 +153,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector }, SubscriptionResponseTimeout = TimeSpan.FromMilliseconds(20) }); @@ -168,6 +174,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -187,6 +194,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -211,6 +219,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -237,6 +246,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -262,6 +272,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); var request = new ClientTestDocument { Id = "command:1", Text = "move" }; @@ -288,6 +299,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -319,6 +331,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector }, OperationResponseTimeout = TimeSpan.FromSeconds(2) }); @@ -348,6 +361,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector }, OperationResponseTimeout = TimeSpan.FromSeconds(5) }); @@ -378,6 +392,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); @@ -403,6 +418,7 @@ public sealed class CultMeshClientTests { RendezvousEndpoints = new[] { "rudp://odin:3076" }, Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() }, + Sessions = CultMeshTestTrust.LocalSessions, Connectors = new[] { connector } }); var opening = mesh.LeaseDocumentAsync(Target, "surface:pilot"); @@ -432,7 +448,7 @@ public sealed class CultMeshClientTests "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), - new[] { "rudp://aetheria:3081" }, + new[] { "rudp://127.0.0.1:3081" }, new[] { "aetheria-daemon" }).ToMessage() } }; diff --git a/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs b/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs index fa11072c..6ec0548f 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshContentSessionTests.cs @@ -55,7 +55,7 @@ public sealed class CultMeshContentSessionTests using var sessions = new CultMeshSessionManager( discovery, Array.Empty(), - new ICultMeshContentTransportConnector[] { connector }); + new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions); var provider = new CultMeshSessionContentProvider( "aetheria.daemon", sessions, @@ -99,7 +99,7 @@ public sealed class CultMeshContentSessionTests using var sessions = new CultMeshSessionManager( discovery, Array.Empty(), - new ICultMeshContentTransportConnector[] { new CultMeshTcpContentTransportConnector() }); + new ICultMeshContentTransportConnector[] { new CultMeshTcpContentTransportConnector() }, CultMeshTestTrust.LocalSessions); Func connect = async () => await sessions.ConnectContentAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon")); @@ -151,7 +151,7 @@ public sealed class CultMeshContentSessionTests using var sessions = new CultMeshSessionManager( discovery, Array.Empty(), - new ICultMeshContentTransportConnector[] { connector }); + new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions); var provider = new CultMeshSessionContentProvider( "aetheria.daemon", sessions, @@ -220,7 +220,7 @@ public sealed class CultMeshContentSessionTests { private readonly string _endpoint; - public RouteSource(string endpoint = "rudp://content.test:3076") => _endpoint = endpoint; + public RouteSource(string endpoint = "rudp://127.0.0.14:3076") => _endpoint = endpoint; public string SourceId => "test-rendezvous"; diff --git a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs index 46e0672f..40fdfe15 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshSessionManagerTests.cs @@ -28,22 +28,22 @@ public sealed class CultMeshSessionManagerTests connected = candidate; return Task.FromResult(new FakeSchemaClient()); }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); await manager.ConnectAsync(Target, CultMeshProtocols.Documents); connected.Should().NotBeNull(); - connected!.Endpoint.Should().Be("rudp://aetheria:3076"); + connected!.Endpoint.Should().Be("rudp://127.0.0.1:3076"); } [Test] public async Task Connect_ReusesInflightAndEstablishedSession() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" }); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var connector = new FakeConnector(async (_, _) => { await release.Task; return new FakeSchemaClient(); }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var target = Target; var first = manager.ConnectAsync(target, CultMeshProtocols.Documents); @@ -61,19 +61,19 @@ public sealed class CultMeshSessionManagerTests public async Task Connect_RacesPathsAndDisposesSuccessfulLoser() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://slow:3076", "rudp://fast:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.3:3076", "rudp://127.0.0.4:3076" }); var slowRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var slowClient = new FakeSchemaClient(); var fastClient = new FakeSchemaClient(); var connector = new FakeConnector(async (candidate, _) => { - if (candidate.Endpoint.Contains("slow")) { await slowRelease.Task; return slowClient; } + if (candidate.Endpoint.Contains("127.0.0.3")) { await slowRelease.Task; return slowClient; } return fastClient; }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents); - session.State.Path!.Endpoint.Should().Be("rudp://fast:3076"); + session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.4:3076"); slowRelease.TrySetResult(); await WaitUntilAsync(() => slowClient.DisposeCount == 1); fastClient.DisposeCount.Should().Be(0); @@ -90,8 +90,8 @@ public sealed class CultMeshSessionManagerTests new CultMeshVerseCompatibility("cultmesh.v0", "rules"), authorityRoutes: new[] { - new CultMeshAuthorityRoute("decoy-daemon", "rudp://decoy:3076"), - new CultMeshAuthorityRoute("aetheria-daemon", "rudp://authority:3076") + new CultMeshAuthorityRoute("decoy-daemon", "rudp://127.0.0.5:3076"), + new CultMeshAuthorityRoute("aetheria-daemon", "rudp://127.0.0.6:3076") }); using var discovery = new CultMeshDiscoveryService( new[] { new StaticRouteSource(clock, descriptor) }, @@ -102,22 +102,22 @@ public sealed class CultMeshSessionManagerTests attempts.Add(candidate.Endpoint); return Task.FromResult(new FakeSchemaClient()); }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions); var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents); - session.State.Path!.Endpoint.Should().Be("rudp://authority:3076"); - attempts.Should().Equal("rudp://authority:3076"); + session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.6:3076"); + attempts.Should().Equal("rudp://127.0.0.6:3076"); } [Test] public void Connect_RejectsRouteWhoseNativeIdentityDoesNotMatchTarget() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://decoy:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" }); var connector = new FakeConnector((_, _) => Task.FromResult(new NativeMismatchClient())); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions); var error = Assert.ThrowsAsync(() => manager.ConnectAsync(Target, CultMeshProtocols.Documents)); @@ -129,10 +129,10 @@ public sealed class CultMeshSessionManagerTests public void Connect_RejectsPortableHandshakeFromWrongRuntime() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://decoy:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" }); var connector = new FakeConnector((_, _) => Task.FromResult(new HandshakeSchemaClient("decoy-daemon"))); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions); var error = Assert.ThrowsAsync(() => manager.ConnectAsync(Target, CultMeshProtocols.Documents)); @@ -148,7 +148,7 @@ public sealed class CultMeshSessionManagerTests "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, new CultMeshVerseCompatibility("cultmesh.v0", "rules"), - discoveryEndpoints: new[] { "rudp://shared:3076" }, + discoveryEndpoints: new[] { "rudp://127.0.0.7:3076" }, authorityRuntimeIds: new[] { "authority-a", "authority-b" }); action.Should().Throw().WithMessage("*ambiguous*"); @@ -158,7 +158,7 @@ public sealed class CultMeshSessionManagerTests public async Task Connect_CallerCancellationDoesNotCancelSharedAttempt() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" }); var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); var connector = new FakeConnector(async (_, token) => { @@ -166,7 +166,7 @@ public sealed class CultMeshSessionManagerTests await release.Task; return new FakeSchemaClient(); }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); using var cancellation = new CancellationTokenSource(); var target = Target; @@ -185,7 +185,7 @@ public sealed class CultMeshSessionManagerTests public async Task BackgroundFailureMigratesSameSessionAcrossEndpointRotation() { var clock = new ManualSessionClock(); - var route = "rudp://first:3076"; + var route = "rudp://127.0.0.8:3076"; using var discovery = Discovery(clock, () => new[] { route }, TimeSpan.FromSeconds(5)); var clients = new List(); var connector = new FakeConnector((_, _) => @@ -194,7 +194,7 @@ public sealed class CultMeshSessionManagerTests clients.Add(client); return Task.FromResult(client); }); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var target = Target; var first = await manager.ConnectAsync(target, CultMeshProtocols.Documents); var states = new List(); @@ -204,14 +204,14 @@ public sealed class CultMeshSessionManagerTests schemaLease.OnCultNet(_ => deliveries++); clients[0].Emit(new CultNetErrorMessage { Error = "before rotation" }); - route = "rudp://second:3076"; + route = "rudp://127.0.0.9:3076"; clients[0].Fail(new IOException("partition")); await WaitUntilAsync(() => first.State.Status == CultMeshSessionStatus.Online && connector.ConnectCount == 2); var second = await manager.ConnectAsync(target, CultMeshProtocols.Documents); clients[1].Emit(new CultNetErrorMessage { Error = "after rotation" }); second.Should().BeSameAs(first); - second.State.Path!.Endpoint.Should().Be("rudp://second:3076"); + second.State.Path!.Endpoint.Should().Be("rudp://127.0.0.9:3076"); states.Should().ContainInOrder(CultMeshSessionStatus.Reconnecting, CultMeshSessionStatus.Online); connector.ConnectCount.Should().Be(2); clients[0].DisposeCount.Should().Be(1); @@ -238,8 +238,8 @@ public sealed class CultMeshSessionManagerTests public void Connect_ReportsUnsupportedPathPrecisely() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "quic://unimplemented:443" }); - using var manager = new CultMeshSessionManager(discovery, Array.Empty(), new CultMeshSessionManagerOptions { Clock = clock }); + using var discovery = Discovery(clock, () => new[] { "quic://127.0.0.10:443" }); + using var manager = new CultMeshSessionManager(discovery, Array.Empty(), CultMeshTestTrust.LocalSessionsWithClock(clock)); var error = Assert.ThrowsAsync(() => manager.ConnectAsync(Target, CultMeshProtocols.Documents)); @@ -253,10 +253,10 @@ public sealed class CultMeshSessionManagerTests public async Task SnapshotSessionsBorrowOneManagedChannelAndDisposeRegistrationsIndependently() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" }); var client = new RespondingSchemaClient(); var connector = new FakeConnector((_, _) => Task.FromResult(client)); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var target = Target; using var first = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions()); using var second = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions()); @@ -274,10 +274,10 @@ public sealed class CultMeshSessionManagerTests public async Task PeerExchangeBorrowsManagedProtocolSessionAcrossRequests() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" }); var client = new PeerExchangeSchemaClient(); var connector = new FakeConnector((_, _) => Task.FromResult(client)); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var exchange = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions { Sessions = manager, Clock = clock }); var target = Target; @@ -294,10 +294,10 @@ public sealed class CultMeshSessionManagerTests public async Task SchemaClientLeasesDisposeHandlersWithoutClosingSession() { var clock = new ManualSessionClock(); - using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" }); + using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" }); var client = new LeaseSchemaClient(); var connector = new FakeConnector((_, _) => Task.FromResult(client)); - using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock }); + using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock)); var session = await manager.ConnectAsync(Target, CultMeshProtocols.Subscriptions); using var first = session.OpenSchemaClient(); using var second = session.OpenSchemaClient(); @@ -363,8 +363,8 @@ public sealed class CultMeshSessionManagerTests var expires = _clock.UtcNow.AddMinutes(1); return Task.FromResult>(new[] { - Observation("norn", "rudp://norn:3076", expires), - Observation("aetheria", "rudp://aetheria:3076", expires) + Observation("norn", "rudp://127.0.0.11:3076", expires), + Observation("aetheria", "rudp://127.0.0.1:3076", expires) }); } @@ -544,7 +544,7 @@ public sealed class CultMeshSessionManagerTests MessageId = request.MessageId, Peers = new[] { - new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://peer-a:3076" }).ToMessage() + new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://127.0.0.12:3076" }).ToMessage() } }; foreach (var handler in _handlers.ToArray()) handler(response); diff --git a/tests/GameCult.Mesh.Tests/CultMeshTestTrust.cs b/tests/GameCult.Mesh.Tests/CultMeshTestTrust.cs new file mode 100644 index 00000000..2356462d --- /dev/null +++ b/tests/GameCult.Mesh.Tests/CultMeshTestTrust.cs @@ -0,0 +1,18 @@ +namespace GameCult.Mesh.Tests; + +internal static class CultMeshTestTrust +{ + internal static CultMeshAuthorityTrustPolicy LocalPolicy { get; } = + new(CultMeshAuthorityTrustMode.LocalDevelopment); + + internal static CultMeshSessionManagerOptions LocalSessions => new() + { + Trust = LocalPolicy + }; + + internal static CultMeshSessionManagerOptions LocalSessionsWithClock(ICultMeshClock clock) => new() + { + Clock = clock, + Trust = LocalPolicy + }; +} diff --git a/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs b/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs index 35b4a71c..979c7230 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshTransportModularityTests.cs @@ -44,7 +44,7 @@ public sealed class CultMeshTransportModularityTests { legacy, new CultMeshTcpSchemaTransportConnector() - }); + }, CultMeshTestTrust.LocalSessions); var session = await sessions.ConnectAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon"), @@ -91,7 +91,7 @@ public sealed class CultMeshTransportModularityTests { legacy, new CultMeshTcpContentTransportConnector() - }); + }, CultMeshTestTrust.LocalSessions); var session = await sessions.ConnectContentAsync(new CultMeshSessionTarget("aetheria", "aetheria.daemon")); using var destination = new MemoryStream(); @@ -111,7 +111,7 @@ public sealed class CultMeshTransportModularityTests }); using var sessions = new CultMeshSessionManager( discovery, - Array.Empty()); + Array.Empty(), CultMeshTestTrust.LocalSessions); Func connect = async () => await sessions.ConnectContentAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon")); @@ -132,7 +132,7 @@ public sealed class CultMeshTransportModularityTests discovery, Array.Empty(), Array.Empty(), - new ICultMeshRealtimeTransportConnector[] { connector }); + new ICultMeshRealtimeTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions); var session = await sessions.ConnectRealtimeAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon")); var sent = new CultMeshRealtimeFrame @@ -163,7 +163,7 @@ public sealed class CultMeshTransportModularityTests }); using var sessions = new CultMeshSessionManager( discovery, - Array.Empty()); + Array.Empty(), CultMeshTestTrust.LocalSessions); Func connect = async () => await sessions.ConnectRealtimeAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon")); @@ -183,7 +183,7 @@ public sealed class CultMeshTransportModularityTests discovery, Array.Empty(), Array.Empty(), - new ICultMeshRealtimeTransportConnector[] { new UnverifiedRealtimeConnector() }); + new ICultMeshRealtimeTransportConnector[] { new UnverifiedRealtimeConnector() }, CultMeshTestTrust.LocalSessions); Func connect = async () => await sessions.ConnectRealtimeAsync( new CultMeshSessionTarget("aetheria", "aetheria.daemon")); diff --git a/tools/GameCult.TransportParity/Program.cs b/tools/GameCult.TransportParity/Program.cs index 31c93e1e..04c71dfe 100644 --- a/tools/GameCult.TransportParity/Program.cs +++ b/tools/GameCult.TransportParity/Program.cs @@ -201,7 +201,8 @@ static async Task MeasureCultMeshQuicStateAsync(int payloadBytes, i [new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions { ValidateProviderCertificate = (_, _, _, _) => true - })]); + })], + LocalDevelopmentSessions()); var session = await sessions.ConnectRealtimeAsync( new CultMeshSessionTarget("parity", "service:parity.provider")); var deadline = DateTime.UtcNow.AddSeconds(5); @@ -363,7 +364,8 @@ static async Task MeasureCultMeshRudpAsync(byte[] payload) discovery, Array.Empty(), [new CultMeshLegacyRudpContentTransportConnector( - new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]); + new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })], + LocalDevelopmentSessions()); var provider = new CultMeshSessionContentProvider( "parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider")); var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N")); @@ -411,7 +413,8 @@ static async Task MeasureCultMeshTcpAsync(byte[] payload) using var sessions = new CultMeshSessionManager( discovery, Array.Empty(), - [new CultMeshTcpContentTransportConnector()]); + [new CultMeshTcpContentTransportConnector()], + LocalDevelopmentSessions()); var provider = new CultMeshSessionContentProvider( "parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider")); var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N")); @@ -456,6 +459,11 @@ static X509Certificate2 CreateCertificate() static string TempPath(string plane) => Path.Combine(Path.GetTempPath(), $"cultmesh-parity-{plane}-{Guid.NewGuid():N}.body"); +static CultMeshSessionManagerOptions LocalDevelopmentSessions() => new() +{ + Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment) +}; + static Socket BindUdpSocket() { var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);