diff --git a/Directory.Build.props b/Directory.Build.props index 434617ff..595da325 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -14,7 +14,7 @@ MIT © Meta Vens and contributors true - 1.0.33 + 1.0.34 $(CultLibPackageVersion) diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index a0596ae2..4dddfa7d 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -53,7 +53,10 @@ export interface CultMeshBrowserAuthorityTrustPolicy { } export interface CultMeshBrowserRendezvous { - resolve(identity: CultMeshBrowserIdentity): Promise; + resolve( + identity: CultMeshBrowserIdentity, + trust?: CultMeshBrowserAuthorityTrustPolicy, + ): Promise; } export interface CultMeshBrowserOdinRendezvousOptions { @@ -101,13 +104,16 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous if (this.#options.maxFrameBytes <= 0) throw new Error("Odin rendezvous maxFrameBytes must be positive."); } - async resolve(identity: CultMeshBrowserIdentity): Promise { + async resolve( + identity: CultMeshBrowserIdentity, + trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] }, + ): Promise { requireText(identity.verseId, "verseId"); requireText(identity.authorityRuntimeId, "authorityRuntimeId"); const failures: Error[] = []; for (const endpoint of this.#options.endpoints) { try { - return await this.resolveFrom(endpoint, identity); + return await this.resolveFrom(endpoint, identity, trust); } catch (error) { failures.push(error instanceof Error ? error : new Error(String(error))); } @@ -118,7 +124,11 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous ); } - private resolveFrom(endpoint: string, identity: CultMeshBrowserIdentity): Promise { + private resolveFrom( + endpoint: string, + identity: CultMeshBrowserIdentity, + trust: CultMeshBrowserAuthorityTrustPolicy, + ): Promise { const socket = this.#options.socketFactory(endpoint); socket.binaryType = "arraybuffer"; const messageId = this.#options.createId(); @@ -169,7 +179,9 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous return; } if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) return; - finish(undefined, selectOdinRoute(message, identity)); + void selectOdinRoute(message, identity, trust) + .then(route => finish(undefined, route)) + .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(`Odin WebSocket could not open '${endpoint}'.`)); @@ -501,6 +513,11 @@ export class CultMeshBrowserClient implements AsyncDisposable { private connectSocket(): Promise { this.throwIfDisposed(); + if (this.#state === "connected" && this.#socket?.readyState === 1) return Promise.resolve(); + if (this.#reconnectTimer) { + clearTimeout(this.#reconnectTimer); + this.#reconnectTimer = undefined; + } if (this.#connectPromise) return this.#connectPromise; const generation = ++this.#socketGeneration; this.setState(this.#socket ? "reconnecting" : "connecting"); @@ -511,7 +528,7 @@ export class CultMeshBrowserClient implements AsyncDisposable { } private async openSocket(generation: number): Promise { - const route = await this.#options.rendezvous.resolve(this.identity); + const route = await this.#options.rendezvous.resolve(this.identity, this.#options.trust); if (route.verseId !== this.identity.verseId || route.authorityRuntimeId !== this.identity.authorityRuntimeId) { throw new Error("CultMesh rendezvous returned a route for the wrong stable identity."); @@ -828,10 +845,11 @@ async function decodeSocketMessage(event: MessageEvent, maxFrameBytes: number): return parseCultNetMessage(decode(payload)); } -function selectOdinRoute( +async function selectOdinRoute( response: CultMeshVerseCatalogResponseMessage, identity: CultMeshBrowserIdentity, -): CultMeshBrowserRoute { + trust: CultMeshBrowserAuthorityTrustPolicy, +): Promise { const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId); if (matchingVerses.length !== 1) { throw new Error( @@ -871,26 +889,38 @@ function selectOdinRoute( if (routes.length === 0) { throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`); } - const route = routes[0]; - return { - ...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, - } } : {}), - }; + const failures: Error[] = []; + for (const route of routes) { + const candidate: CultMeshBrowserRoute = { + ...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, + } } : {}), + }; + try { + await verifyAuthorityRoute(candidate, trust); + return candidate; + } catch (error) { + failures.push(error instanceof Error ? error : new Error(String(error))); + } + } + throw new AggregateError( + failures, + `Odin advertised no trusted browser route for authority runtime '${identity.authorityRuntimeId}'.`, + ); } async function validateSessionAcceptance( diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 4f90acf1..6c169d93 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -9,6 +9,7 @@ import { type CultNetMessage, type CultNetOperationRequestMessage, type CultNetRawDocumentRecord, + type CultMeshVerseCatalogResponseMessage, type CultMeshSessionOpenMessage, } from "cultnet-ts/contracts"; @@ -38,16 +39,16 @@ test("Odin rendezvous resolves stable authority-runtime identity and observes ro assert.equal((await rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", - })).endpoint, "ws://127.0.0.1:4050/mesh"); + }, localTrust)).endpoint, "ws://127.0.0.1:4050/mesh"); odin.providerEndpoint = "ws://127.0.0.1:4060/mesh"; assert.equal((await rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", - })).endpoint, "ws://127.0.0.1:4060/mesh"); + }, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh"); await assert.rejects( - rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }), + rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust), /could not resolve/, ); }); @@ -64,7 +65,7 @@ test("Odin rendezvous never selects another authority's faster endpoint", async const route = await rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", - }); + }, localTrust); assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh"); }); @@ -80,18 +81,45 @@ test("Odin rendezvous accepts one-authority legacy routes and rejects ambiguous const route = await rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon", - }); + }, localTrust); assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh"); odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy"; await assert.rejects( - rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }), + rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "sample.counter-daemon" }, localTrust), (error: unknown) => error instanceof AggregateError && error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)), ); }); +test("Odin rendezvous rejects a poisoned route without vetoing a certified fallback", async () => { + const signed = await createSignedRoute("wss://provider.example/mesh"); + const odin = new FakeOdin(signed.route.endpoint); + odin.authorityRoutesOverride = [ + { + authorityRuntimeId: signed.route.authorityRuntimeId, + endpoint: "wss://poison.example/mesh", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "poisoned", + }, + routeForCatalog(signed.route), + ]; + const rendezvous = new CultMeshBrowserOdinRendezvous({ + endpoints: ["ws://127.0.0.1:4040/odin"], + runtimeId: "browser-certified-fallback-test", + socketFactory: () => odin.open(), + }); + + const selected = await rendezvous.resolve( + { verseId: signed.route.verseId, authorityRuntimeId: signed.route.authorityRuntimeId }, + { mode: "authenticated-remote", odinRoots: [signed.odinPublic] }, + ); + + assert.equal(selected.endpoint, signed.route.endpoint); +}); + test("browser client leases provider state, invokes an operation, and follows route replacement", async () => { const first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4); const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9); @@ -362,6 +390,37 @@ test("browser client replays one idempotent operation after route loss without d await client.dispose(); }); +test("an early caller reconnect cancels the scheduled reconnect and keeps one live socket", async () => { + const provider = new FakeProvider("ws://127.0.0.1:4361/mesh", 2); + const client = await CultMeshBrowserClient.connect({ + ...provider.route, + runtimeId: "browser-single-reconnect-test", + rendezvous: { resolve: async () => provider.route }, + trust: localTrust, + reconnectDelayMs: 40, + socketFactory: () => provider.open(), + }); + const lease = await client.leaseRawDocument({ + schemaId: "sample.counter_state.v1", + recordKey: "counter:main", + }); + + provider.dropConnections(); + await client.invoke({ + serviceId: "sample.counter", + operation: "increment", + payloadSchema: "sample.increment.v1", + payload: { amount: 1 }, + }); + await new Promise(resolve => setTimeout(resolve, 80)); + + assert.equal(provider.openCount, 2); + assert.equal(provider.liveSocketCount, 1); + assert.equal(provider.activeSubscriptionCount, 1); + lease.dispose(); + 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", @@ -443,6 +502,7 @@ class FakeProvider { #rejectOperations: boolean; #runtimeId: string; #responseRuntimeId: string; + #openCount = 0; constructor( endpoint: string, @@ -469,7 +529,20 @@ class FakeProvider { return count; } + get openCount(): number { + return this.#openCount; + } + + get liveSocketCount(): number { + return this.#subscriptions.size; + } + + dropConnections(): void { + for (const socket of [...this.#subscriptions.keys()]) socket.close(1012, "route dropped"); + } + open(): FakeSocket { + this.#openCount++; const socket = new FakeSocket( wire => this.receive(socket, wire), () => this.#subscriptions.delete(socket), @@ -587,6 +660,7 @@ class FakeOdin { providerEndpoint: string; decoyEndpoint: string | undefined; omitAuthorityRoutes = false; + authorityRoutesOverride: NonNullable | undefined; constructor(providerEndpoint: string) { this.providerEndpoint = providerEndpoint; @@ -614,7 +688,7 @@ class FakeOdin { authorityRuntimeIds: this.decoyEndpoint ? ["sample.counter-daemon", "sample.decoy-daemon"] : ["sample.counter-daemon"], - ...(!this.omitAuthorityRoutes ? { authorityRoutes: [ + ...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [ ...(this.decoyEndpoint ? [{ authorityRuntimeId: "sample.decoy-daemon", endpoint: this.decoyEndpoint, @@ -637,6 +711,29 @@ class FakeOdin { } } +function routeForCatalog(route: CultMeshBrowserRoute): NonNullable< + CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"] +>[number] { + return { + authorityRuntimeId: route.authorityRuntimeId, + endpoint: route.endpoint, + protocolIds: [...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])], + priority: route.priority ?? 0, + generation: route.generation, + ...(route.certificate ? { + certificate: { + 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, + }, + } : {}), + }; +} + function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket { let socket: FakeSocket; socket = new FakeSocket(wire => { diff --git a/samples/eve-browser-network/EveBrowserNetworkSample.csproj b/samples/eve-browser-network/EveBrowserNetworkSample.csproj index c83a42df..5dde9995 100644 --- a/samples/eve-browser-network/EveBrowserNetworkSample.csproj +++ b/samples/eve-browser-network/EveBrowserNetworkSample.csproj @@ -5,8 +5,8 @@ enable $(MSBuildThisFileDirectory)..\..\..\Eve false - 1.0.33 - 0.3.2 + 1.0.34 + 0.3.3 $(PackageFeed) diff --git a/scripts/pack-nuget.ps1 b/scripts/pack-nuget.ps1 index fa0d12bd..2b853aab 100644 --- a/scripts/pack-nuget.ps1 +++ b/scripts/pack-nuget.ps1 @@ -71,6 +71,7 @@ if (-not $SkipConsumerSmoke) { Remove-Item -LiteralPath $smokeRoot -Recurse -Force } New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null + $consumerPackages = Join-Path $smokeRoot "packages" $projectDocument = @" @@ -78,6 +79,8 @@ if (-not $SkipConsumerSmoke) { Exe enable $outputRoot;https://api.nuget.org/v3/index.json + $consumerPackages + true diff --git a/scripts/verify-eve-getting-started.ps1 b/scripts/verify-eve-getting-started.ps1 index e4143645..26c69f14 100644 --- a/scripts/verify-eve-getting-started.ps1 +++ b/scripts/verify-eve-getting-started.ps1 @@ -32,8 +32,8 @@ if ($LASTEXITCODE -ne 0 -or [string]::IsNullOrWhiteSpace($sourceRevision)) { throw "Could not determine the CultLib source revision for local package identity." } $verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8) -$cultLibLocalVersion = "1.0.33-local.$sourceRevision.$verificationNonce" -$surfaceLocalVersion = "0.3.2-local.$sourceRevision.$verificationNonce" +$cultLibLocalVersion = "1.0.34-local.$sourceRevision.$verificationNonce" +$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce" try { & (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") ` -CultLibRoot $cultLibRoot ` diff --git a/src/GameCult.Mesh/CultMeshSessions.cs b/src/GameCult.Mesh/CultMeshSessions.cs index b0dace01..9768dfd5 100644 --- a/src/GameCult.Mesh/CultMeshSessions.cs +++ b/src/GameCult.Mesh/CultMeshSessions.cs @@ -1084,16 +1084,21 @@ namespace GameCult.Mesh CultMeshSessionTarget target, CultMeshProtocolId protocol) { - return discovery.Candidates + var routes = discovery.Candidates .SelectMany(candidate => candidate.Descriptor.AuthorityRoutes) .Where(route => string.Equals( route.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal)) .Where(route => route.Supports(protocol)) - .GroupBy(route => route.Endpoint, StringComparer.Ordinal) - .Select(group => group.OrderBy(route => route.Priority).First()) - .Select(route => new CultMeshTransportCandidate( + .ToArray(); + var verified = new List(); + var rejected = new List(); + foreach (var route in routes) + { + try + { + verified.Add(new CultMeshTransportCandidate( route.Endpoint, priority: route.Priority, authorityRuntimeId: route.AuthorityRuntimeId, @@ -1102,7 +1107,23 @@ namespace GameCult.Mesh .WithVerifiedAuthority(_options.Trust.Verify( target.VerseId, route, - _options.Clock.UtcNow))) + _options.Clock.UtcNow))); + } + catch (CultMeshSessionException error) + { + rejected.Add(error); + } + } + if (verified.Count == 0 && rejected.Count > 0) + { + throw Failure( + CultMeshSessionFailureReason.Authentication, + $"No trusted route remained for CultMesh target '{target}' and protocol '{protocol}'.", + new AggregateException(rejected)); + } + return verified + .GroupBy(candidate => candidate.Endpoint, StringComparer.Ordinal) + .Select(group => group.OrderBy(candidate => candidate.Priority).First()) .OrderBy(candidate => candidate.Priority) .ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal) .ToArray(); diff --git a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs index c4ee15c7..1e98fc16 100644 --- a/src/GameCult.Mesh/CultMeshVerseDiscovery.cs +++ b/src/GameCult.Mesh/CultMeshVerseDiscovery.cs @@ -207,6 +207,12 @@ namespace GameCult.Mesh /// public Func? CreateClient { get; set; } + /// + /// Gets or sets an endpoint-aware client factory for external transport packages. + /// When present, this owns discovery transport selection for each exact Odin route. + /// + public Func? CreateClientForEndpoint { get; set; } + /// Gets or sets the clock that owns discovery deadlines. public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance; @@ -341,7 +347,8 @@ namespace GameCult.Mesh private ICultNetSchemaClient CreateClient(string endpoint) { - return _options.CreateClient?.Invoke() + return _options.CreateClientForEndpoint?.Invoke(endpoint) + ?? _options.CreateClient?.Invoke() ?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient); } 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 28cc06ad..47905fec 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 @@ -16,6 +16,7 @@ become usable by merely repeating the expected Verse and runtime strings. ```csharp using GameCult.Mesh; +using GameCult.Networking.WebSockets; var odinEndpoint = Environment.GetEnvironmentVariable("ODIN_ENDPOINT") ?? throw new InvalidOperationException("Set ODIN_ENDPOINT to the configured rendezvous route."); @@ -33,11 +34,22 @@ var odinRoot = new CultMeshEcdsaP256PublicKey( using var mesh = new CultMeshClient(new CultMeshClientOptions { RendezvousEndpoints = new[] { odinEndpoint }, + Discovery = new CultMeshVerseDiscoveryClientOptions + { + CreateClientForEndpoint = _ => new CultNetWebSocketSchemaClient() + }, Sessions = new CultMeshSessionManagerOptions { Trust = new CultMeshAuthorityTrustPolicy( CultMeshAuthorityTrustMode.AuthenticatedRemote, new[] { odinRoot }) + }, + Connectors = new ICultMeshTransportConnector[] + { + new CultMeshUriSchemaTransportConnector( + "cultnet-websocket", + new[] { "wss" }, + _ => new CultNetWebSocketSchemaClient()) } }); @@ -93,6 +105,30 @@ 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. +The remote example requires both `GameCult.Mesh` and +`GameCult.Networking.WebSockets` at the same CultLib package version. Core +CultMesh does not silently install a network stack: the consumer chooses the +secure connector and its TLS policy. The default TCP connector exists for the +explicit loopback-development case and cannot satisfy authenticated-remote +trust. + +An authenticated provider receives an Odin-certified route and its matching +provider private key from deployment configuration. It attaches the proof gate +to the same WSS schema server before publishing the route: + +```csharp +using var identity = new CultMeshSessionIdentityServer( + schemaServer, + authorityRuntimeId: "sample.counter-daemon", + verseIds: new[] { "sample.counter" }, + protocolIds: new[] { CultMeshProtocols.Documents.Value }, + routeGenerations: new[] { certifiedRoute.Generation }, + proofSigners: new[] { new CultMeshSessionProofSigner(certifiedRoute, providerPrivateKey) }); +``` + +`certifiedRoute` is the exact signed route Odin publishes. The provider never +holds Odin's signing key, and an unsigned WSS route is still untrusted. + Fast session-management check (this uses controlled transports; it is not the network proof): diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs index 88c7e803..246e510a 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs @@ -134,6 +134,45 @@ public sealed class CultMeshAuthorityProofTests .Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority); } + [Test] + public async Task SessionManagerRejectsOnlyThePoisonedRouteAndUsesCertifiedFallback() + { + using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256); + using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256); + var now = DateTimeOffset.UtcNow; + var valid = CultMeshAuthorityProof.CreateSignedRoute( + "aetheria", "aetheria-daemon", "wss://provider.example/mesh", + new[] { CultMeshProtocols.Documents.Value }, 10, "generation-valid", + CultMeshEcdsaP256PublicKey.From("provider-valid", provider), "odin-valid", + now.AddMinutes(-1), now.AddMinutes(5), odin); + var poisoned = new CultMeshAuthorityRoute( + "aetheria-daemon", "wss://poison.example/mesh", + new[] { CultMeshProtocols.Documents.Value }, priority: 0, generation: "generation-poisoned"); + var descriptor = new CultMeshVerseDescriptor( + "aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster, + new CultMeshVerseCompatibility("cultmesh.v0", "rules"), + authorityRoutes: new[] { poisoned, valid }); + using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) }); + var signer = new CultMeshSessionProofSigner(valid, provider); + var connector = new SigningConnector(signer); + using var manager = new CultMeshSessionManager( + discovery, + new[] { connector }, + new CultMeshSessionManagerOptions + { + Trust = new CultMeshAuthorityTrustPolicy( + CultMeshAuthorityTrustMode.AuthenticatedRemote, + new[] { CultMeshEcdsaP256PublicKey.From("odin-valid", odin) }) + }); + + var session = await manager.ConnectAsync( + new CultMeshSessionTarget("aetheria", "aetheria-daemon"), + CultMeshProtocols.Documents); + + session.State.Path!.Endpoint.Should().Be(valid.Endpoint); + connector.Attempts.Should().Equal(valid.Endpoint); + } + private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) => CultMeshAuthorityProof.CreateSignedRoute( "aetheria", @@ -238,4 +277,53 @@ public sealed class CultMeshAuthorityProofTests } public void Dispose() { } } + + private sealed class SigningConnector : ICultMeshTransportConnector + { + private readonly CultMeshSessionProofSigner _signer; + public SigningConnector(CultMeshSessionProofSigner signer) => _signer = signer; + public List Attempts { get; } = new(); + public string ConnectorId => "signed-provider"; + public int Priority => 0; + public bool CanConnect(CultMeshTransportCandidate candidate) => true; + public Task ConnectAsync( + CultMeshTransportCandidate candidate, + CultMeshProtocolId protocol, + CancellationToken cancellationToken = default) + { + Attempts.Add(candidate.Endpoint); + return Task.FromResult(new SigningClient(_signer)); + } + } + + private sealed class SigningClient : ICultNetSchemaClient + { + private readonly CultMeshSessionProofSigner _signer; + private Action? _accepted; + public SigningClient(CultMeshSessionProofSigner signer) => _signer = signer; + 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 = _signer.ProviderKeyId, + ProviderSignature = _signer.Sign(request) + }); + } + 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/unity/org.gamecult.cultlib/package.json b/unity/org.gamecult.cultlib/package.json index 7c2cce1c..b0a00486 100644 --- a/unity/org.gamecult.cultlib/package.json +++ b/unity/org.gamecult.cultlib/package.json @@ -1,7 +1,7 @@ { "name": "org.gamecult.cultlib", "displayName": "CultLib", - "version": "1.0.47", + "version": "1.0.48", "unity": "2021.3", "description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.", "license": "MIT",