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",