Harden CultMesh route fallback and secure onboarding

This commit is contained in:
Meta Vens 2026-08-18 19:41:03 +02:00
commit c194bd4cc8
11 changed files with 329 additions and 47 deletions

View file

@ -14,7 +14,7 @@
<PackageLicenseExpression>MIT</PackageLicenseExpression>
<Copyright>© Meta Vens and contributors</Copyright>
<PublishRepositoryUrl>true</PublishRepositoryUrl>
<Version>1.0.33</Version>
<Version>1.0.34</Version>
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
<!-- Symbols/debugging -->

View file

@ -53,7 +53,10 @@ export interface CultMeshBrowserAuthorityTrustPolicy {
}
export interface CultMeshBrowserRendezvous {
resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute>;
resolve(
identity: CultMeshBrowserIdentity,
trust?: CultMeshBrowserAuthorityTrustPolicy,
): Promise<CultMeshBrowserRoute>;
}
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<CultMeshBrowserRoute> {
async resolve(
identity: CultMeshBrowserIdentity,
trust: CultMeshBrowserAuthorityTrustPolicy = { mode: "authenticated-remote", odinRoots: [] },
): Promise<CultMeshBrowserRoute> {
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<CultMeshBrowserRoute> {
private resolveFrom(
endpoint: string,
identity: CultMeshBrowserIdentity,
trust: CultMeshBrowserAuthorityTrustPolicy,
): Promise<CultMeshBrowserRoute> {
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<void> {
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<void> {
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<CultMeshBrowserRoute> {
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(

View file

@ -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<CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"]> | 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 => {

View file

@ -5,8 +5,8 @@
<Nullable>enable</Nullable>
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.33</CultLibPackageVersion>
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.2</EveSurfacePackageVersion>
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.34</CultLibPackageVersion>
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.3</EveSurfacePackageVersion>
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
</PropertyGroup>
<ItemGroup Condition="'$(UsePackageArtifacts)' != 'true'">

View file

@ -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 = @"
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
@ -78,6 +79,8 @@ if (-not $SkipConsumerSmoke) {
<OutputType>Exe</OutputType>
<ImplicitUsings>enable</ImplicitUsings>
<RestoreSources>$outputRoot;https://api.nuget.org/v3/index.json</RestoreSources>
<RestorePackagesPath>$consumerPackages</RestorePackagesPath>
<RestoreNoCache>true</RestoreNoCache>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="GameCult.Mesh" Version="$version" />

View file

@ -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 `

View file

@ -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<CultMeshTransportCandidate>();
var rejected = new List<Exception>();
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();

View file

@ -207,6 +207,12 @@ namespace GameCult.Mesh
/// </summary>
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
/// <summary>
/// Gets or sets an endpoint-aware client factory for external transport packages.
/// When present, this owns discovery transport selection for each exact Odin route.
/// </summary>
public Func<string, ICultNetSchemaClient>? CreateClientForEndpoint { get; set; }
/// <summary>Gets or sets the clock that owns discovery deadlines.</summary>
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);
}

View file

@ -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):

View file

@ -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<string> Attempts { get; } = new();
public string ConnectorId => "signed-provider";
public int Priority => 0;
public bool CanConnect(CultMeshTransportCandidate candidate) => true;
public Task<ICultNetSchemaClient> ConnectAsync(
CultMeshTransportCandidate candidate,
CultMeshProtocolId protocol,
CancellationToken cancellationToken = default)
{
Attempts.Add(candidate.Endpoint);
return Task.FromResult<ICultNetSchemaClient>(new SigningClient(_signer));
}
}
private sealed class SigningClient : ICultNetSchemaClient
{
private readonly CultMeshSessionProofSigner _signer;
private Action<CultMeshSessionAcceptedMessage>? _accepted;
public SigningClient(CultMeshSessionProofSigner signer) => _signer = signer;
public bool Connected => true;
public void Connect(string host, int port) { }
public void SendCultNet<T>(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<T>(Action<T> callback) where T : ICultNetSchemaMessage
{
if (typeof(T) == typeof(CultMeshSessionAcceptedMessage))
_accepted = response => callback((T)(object)response);
}
public void Dispose() { }
}
}

View file

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