Harden CultMesh route fallback and secure onboarding
This commit is contained in:
parent
f622ee1b24
commit
c194bd4cc8
11 changed files with 329 additions and 47 deletions
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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'">
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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 `
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
||||
|
|
|
|||
|
|
@ -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() { }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue