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>
|
<PackageLicenseExpression>MIT</PackageLicenseExpression>
|
||||||
<Copyright>© Meta Vens and contributors</Copyright>
|
<Copyright>© Meta Vens and contributors</Copyright>
|
||||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||||
<Version>1.0.33</Version>
|
<Version>1.0.34</Version>
|
||||||
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
|
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
|
||||||
|
|
||||||
<!-- Symbols/debugging -->
|
<!-- Symbols/debugging -->
|
||||||
|
|
|
||||||
|
|
@ -53,7 +53,10 @@ export interface CultMeshBrowserAuthorityTrustPolicy {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CultMeshBrowserRendezvous {
|
export interface CultMeshBrowserRendezvous {
|
||||||
resolve(identity: CultMeshBrowserIdentity): Promise<CultMeshBrowserRoute>;
|
resolve(
|
||||||
|
identity: CultMeshBrowserIdentity,
|
||||||
|
trust?: CultMeshBrowserAuthorityTrustPolicy,
|
||||||
|
): Promise<CultMeshBrowserRoute>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CultMeshBrowserOdinRendezvousOptions {
|
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.");
|
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.verseId, "verseId");
|
||||||
requireText(identity.authorityRuntimeId, "authorityRuntimeId");
|
requireText(identity.authorityRuntimeId, "authorityRuntimeId");
|
||||||
const failures: Error[] = [];
|
const failures: Error[] = [];
|
||||||
for (const endpoint of this.#options.endpoints) {
|
for (const endpoint of this.#options.endpoints) {
|
||||||
try {
|
try {
|
||||||
return await this.resolveFrom(endpoint, identity);
|
return await this.resolveFrom(endpoint, identity, trust);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failures.push(error instanceof Error ? error : new Error(String(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);
|
const socket = this.#options.socketFactory(endpoint);
|
||||||
socket.binaryType = "arraybuffer";
|
socket.binaryType = "arraybuffer";
|
||||||
const messageId = this.#options.createId();
|
const messageId = this.#options.createId();
|
||||||
|
|
@ -169,7 +179,9 @@ export class CultMeshBrowserOdinRendezvous implements CultMeshBrowserRendezvous
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (message.schemaVersion !== "cultmesh.verse_catalog_response.v0" || message.messageId !== messageId) 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))));
|
}).catch(error => finish(error instanceof Error ? error : new Error(String(error))));
|
||||||
};
|
};
|
||||||
socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`));
|
socket.onerror = () => finish(new Error(`Odin WebSocket could not open '${endpoint}'.`));
|
||||||
|
|
@ -501,6 +513,11 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
|
|
||||||
private connectSocket(): Promise<void> {
|
private connectSocket(): Promise<void> {
|
||||||
this.throwIfDisposed();
|
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;
|
if (this.#connectPromise) return this.#connectPromise;
|
||||||
const generation = ++this.#socketGeneration;
|
const generation = ++this.#socketGeneration;
|
||||||
this.setState(this.#socket ? "reconnecting" : "connecting");
|
this.setState(this.#socket ? "reconnecting" : "connecting");
|
||||||
|
|
@ -511,7 +528,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
||||||
}
|
}
|
||||||
|
|
||||||
private async openSocket(generation: number): Promise<void> {
|
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 ||
|
if (route.verseId !== this.identity.verseId ||
|
||||||
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
|
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
|
||||||
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
|
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));
|
return parseCultNetMessage(decode(payload));
|
||||||
}
|
}
|
||||||
|
|
||||||
function selectOdinRoute(
|
async function selectOdinRoute(
|
||||||
response: CultMeshVerseCatalogResponseMessage,
|
response: CultMeshVerseCatalogResponseMessage,
|
||||||
identity: CultMeshBrowserIdentity,
|
identity: CultMeshBrowserIdentity,
|
||||||
): CultMeshBrowserRoute {
|
trust: CultMeshBrowserAuthorityTrustPolicy,
|
||||||
|
): Promise<CultMeshBrowserRoute> {
|
||||||
const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId);
|
const matchingVerses = response.verses.filter(verse => verse.verseId === identity.verseId);
|
||||||
if (matchingVerses.length !== 1) {
|
if (matchingVerses.length !== 1) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
|
|
@ -871,26 +889,38 @@ function selectOdinRoute(
|
||||||
if (routes.length === 0) {
|
if (routes.length === 0) {
|
||||||
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
|
throw new Error(`Odin advertised no browser-compatible route for authority runtime '${identity.authorityRuntimeId}'.`);
|
||||||
}
|
}
|
||||||
const route = routes[0];
|
const failures: Error[] = [];
|
||||||
return {
|
for (const route of routes) {
|
||||||
...identity,
|
const candidate: CultMeshBrowserRoute = {
|
||||||
endpoint: route.endpoint,
|
...identity,
|
||||||
protocolId: "cultmesh.documents.v1",
|
endpoint: route.endpoint,
|
||||||
protocolIds: route.protocolIds,
|
protocolId: "cultmesh.documents.v1",
|
||||||
priority: route.priority,
|
protocolIds: route.protocolIds,
|
||||||
generation: route.generation,
|
priority: route.priority,
|
||||||
...(route.certificate ? { certificate: {
|
generation: route.generation,
|
||||||
providerKey: {
|
...(route.certificate ? { certificate: {
|
||||||
keyId: route.certificate.providerKeyId,
|
providerKey: {
|
||||||
x: route.certificate.providerPublicKeyX,
|
keyId: route.certificate.providerKeyId,
|
||||||
y: route.certificate.providerPublicKeyY,
|
x: route.certificate.providerPublicKeyX,
|
||||||
},
|
y: route.certificate.providerPublicKeyY,
|
||||||
odinKeyId: route.certificate.odinKeyId,
|
},
|
||||||
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
|
odinKeyId: route.certificate.odinKeyId,
|
||||||
expiresAtUnixMilliseconds: route.certificate.expiresAtUnixMilliseconds,
|
issuedAtUnixMilliseconds: route.certificate.issuedAtUnixMilliseconds,
|
||||||
signature: route.certificate.signature,
|
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(
|
async function validateSessionAcceptance(
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,7 @@ import {
|
||||||
type CultNetMessage,
|
type CultNetMessage,
|
||||||
type CultNetOperationRequestMessage,
|
type CultNetOperationRequestMessage,
|
||||||
type CultNetRawDocumentRecord,
|
type CultNetRawDocumentRecord,
|
||||||
|
type CultMeshVerseCatalogResponseMessage,
|
||||||
type CultMeshSessionOpenMessage,
|
type CultMeshSessionOpenMessage,
|
||||||
} from "cultnet-ts/contracts";
|
} from "cultnet-ts/contracts";
|
||||||
|
|
||||||
|
|
@ -38,16 +39,16 @@ test("Odin rendezvous resolves stable authority-runtime identity and observes ro
|
||||||
assert.equal((await rendezvous.resolve({
|
assert.equal((await rendezvous.resolve({
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
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";
|
odin.providerEndpoint = "ws://127.0.0.1:4060/mesh";
|
||||||
assert.equal((await rendezvous.resolve({
|
assert.equal((await rendezvous.resolve({
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
})).endpoint, "ws://127.0.0.1:4060/mesh");
|
}, localTrust)).endpoint, "ws://127.0.0.1:4060/mesh");
|
||||||
|
|
||||||
await assert.rejects(
|
await assert.rejects(
|
||||||
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }),
|
rendezvous.resolve({ verseId: "sample.counter", authorityRuntimeId: "other-runtime" }, localTrust),
|
||||||
/could not resolve/,
|
/could not resolve/,
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -64,7 +65,7 @@ test("Odin rendezvous never selects another authority's faster endpoint", async
|
||||||
const route = await rendezvous.resolve({
|
const route = await rendezvous.resolve({
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
});
|
}, localTrust);
|
||||||
assert.equal(route.endpoint, "ws://127.0.0.1:4050/mesh");
|
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({
|
const route = await rendezvous.resolve({
|
||||||
verseId: "sample.counter",
|
verseId: "sample.counter",
|
||||||
authorityRuntimeId: "sample.counter-daemon",
|
authorityRuntimeId: "sample.counter-daemon",
|
||||||
});
|
}, localTrust);
|
||||||
assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh");
|
assert.equal(route.endpoint, "ws://127.0.0.1:4070/mesh");
|
||||||
|
|
||||||
odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy";
|
odin.decoyEndpoint = "ws://127.0.0.1:4069/decoy";
|
||||||
await assert.rejects(
|
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: unknown) =>
|
||||||
error instanceof AggregateError &&
|
error instanceof AggregateError &&
|
||||||
error.errors.some(inner => inner instanceof Error && /ambiguous legacy routes/.test(inner.message)),
|
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 () => {
|
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 first = new FakeProvider("ws://127.0.0.1:4101/mesh", 4);
|
||||||
const second = new FakeProvider("ws://127.0.0.1:4102/mesh", 9);
|
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();
|
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 () => {
|
test("browser client rejects an operation response from another runtime", async () => {
|
||||||
const provider = new FakeProvider(
|
const provider = new FakeProvider(
|
||||||
"ws://127.0.0.1:4371/mesh",
|
"ws://127.0.0.1:4371/mesh",
|
||||||
|
|
@ -443,6 +502,7 @@ class FakeProvider {
|
||||||
#rejectOperations: boolean;
|
#rejectOperations: boolean;
|
||||||
#runtimeId: string;
|
#runtimeId: string;
|
||||||
#responseRuntimeId: string;
|
#responseRuntimeId: string;
|
||||||
|
#openCount = 0;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
endpoint: string,
|
endpoint: string,
|
||||||
|
|
@ -469,7 +529,20 @@ class FakeProvider {
|
||||||
return count;
|
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 {
|
open(): FakeSocket {
|
||||||
|
this.#openCount++;
|
||||||
const socket = new FakeSocket(
|
const socket = new FakeSocket(
|
||||||
wire => this.receive(socket, wire),
|
wire => this.receive(socket, wire),
|
||||||
() => this.#subscriptions.delete(socket),
|
() => this.#subscriptions.delete(socket),
|
||||||
|
|
@ -587,6 +660,7 @@ class FakeOdin {
|
||||||
providerEndpoint: string;
|
providerEndpoint: string;
|
||||||
decoyEndpoint: string | undefined;
|
decoyEndpoint: string | undefined;
|
||||||
omitAuthorityRoutes = false;
|
omitAuthorityRoutes = false;
|
||||||
|
authorityRoutesOverride: NonNullable<CultMeshVerseCatalogResponseMessage["verses"][number]["authorityRoutes"]> | undefined;
|
||||||
|
|
||||||
constructor(providerEndpoint: string) {
|
constructor(providerEndpoint: string) {
|
||||||
this.providerEndpoint = providerEndpoint;
|
this.providerEndpoint = providerEndpoint;
|
||||||
|
|
@ -614,7 +688,7 @@ class FakeOdin {
|
||||||
authorityRuntimeIds: this.decoyEndpoint
|
authorityRuntimeIds: this.decoyEndpoint
|
||||||
? ["sample.counter-daemon", "sample.decoy-daemon"]
|
? ["sample.counter-daemon", "sample.decoy-daemon"]
|
||||||
: ["sample.counter-daemon"],
|
: ["sample.counter-daemon"],
|
||||||
...(!this.omitAuthorityRoutes ? { authorityRoutes: [
|
...(!this.omitAuthorityRoutes ? { authorityRoutes: this.authorityRoutesOverride ?? [
|
||||||
...(this.decoyEndpoint ? [{
|
...(this.decoyEndpoint ? [{
|
||||||
authorityRuntimeId: "sample.decoy-daemon",
|
authorityRuntimeId: "sample.decoy-daemon",
|
||||||
endpoint: this.decoyEndpoint,
|
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 {
|
function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket {
|
||||||
let socket: FakeSocket;
|
let socket: FakeSocket;
|
||||||
socket = new FakeSocket(wire => {
|
socket = new FakeSocket(wire => {
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,8 @@
|
||||||
<Nullable>enable</Nullable>
|
<Nullable>enable</Nullable>
|
||||||
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
|
<EveRoot Condition="'$(EveRoot)' == ''">$(MSBuildThisFileDirectory)..\..\..\Eve</EveRoot>
|
||||||
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
|
<UsePackageArtifacts Condition="'$(UsePackageArtifacts)' == ''">false</UsePackageArtifacts>
|
||||||
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.33</CultLibPackageVersion>
|
<CultLibPackageVersion Condition="'$(CultLibPackageVersion)' == ''">1.0.34</CultLibPackageVersion>
|
||||||
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.2</EveSurfacePackageVersion>
|
<EveSurfacePackageVersion Condition="'$(EveSurfacePackageVersion)' == ''">0.3.3</EveSurfacePackageVersion>
|
||||||
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
|
<RestoreAdditionalProjectSources Condition="'$(PackageFeed)' != ''">$(PackageFeed)</RestoreAdditionalProjectSources>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup Condition="'$(UsePackageArtifacts)' != 'true'">
|
<ItemGroup Condition="'$(UsePackageArtifacts)' != 'true'">
|
||||||
|
|
|
||||||
|
|
@ -71,6 +71,7 @@ if (-not $SkipConsumerSmoke) {
|
||||||
Remove-Item -LiteralPath $smokeRoot -Recurse -Force
|
Remove-Item -LiteralPath $smokeRoot -Recurse -Force
|
||||||
}
|
}
|
||||||
New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null
|
New-Item -ItemType Directory -Force -Path $smokeRoot | Out-Null
|
||||||
|
$consumerPackages = Join-Path $smokeRoot "packages"
|
||||||
$projectDocument = @"
|
$projectDocument = @"
|
||||||
<Project Sdk="Microsoft.NET.Sdk">
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
|
|
@ -78,6 +79,8 @@ if (-not $SkipConsumerSmoke) {
|
||||||
<OutputType>Exe</OutputType>
|
<OutputType>Exe</OutputType>
|
||||||
<ImplicitUsings>enable</ImplicitUsings>
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
<RestoreSources>$outputRoot;https://api.nuget.org/v3/index.json</RestoreSources>
|
<RestoreSources>$outputRoot;https://api.nuget.org/v3/index.json</RestoreSources>
|
||||||
|
<RestorePackagesPath>$consumerPackages</RestorePackagesPath>
|
||||||
|
<RestoreNoCache>true</RestoreNoCache>
|
||||||
</PropertyGroup>
|
</PropertyGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<PackageReference Include="GameCult.Mesh" Version="$version" />
|
<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."
|
throw "Could not determine the CultLib source revision for local package identity."
|
||||||
}
|
}
|
||||||
$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
$verificationNonce = [guid]::NewGuid().ToString("N").Substring(0, 8)
|
||||||
$cultLibLocalVersion = "1.0.33-local.$sourceRevision.$verificationNonce"
|
$cultLibLocalVersion = "1.0.34-local.$sourceRevision.$verificationNonce"
|
||||||
$surfaceLocalVersion = "0.3.2-local.$sourceRevision.$verificationNonce"
|
$surfaceLocalVersion = "0.3.3-local.$sourceRevision.$verificationNonce"
|
||||||
try {
|
try {
|
||||||
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `
|
& (Join-Path $EveRoot "scripts\pack-dotnet-surface.ps1") `
|
||||||
-CultLibRoot $cultLibRoot `
|
-CultLibRoot $cultLibRoot `
|
||||||
|
|
|
||||||
|
|
@ -1084,16 +1084,21 @@ namespace GameCult.Mesh
|
||||||
CultMeshSessionTarget target,
|
CultMeshSessionTarget target,
|
||||||
CultMeshProtocolId protocol)
|
CultMeshProtocolId protocol)
|
||||||
{
|
{
|
||||||
return discovery.Candidates
|
var routes = discovery.Candidates
|
||||||
.SelectMany(candidate => candidate.Descriptor.AuthorityRoutes)
|
.SelectMany(candidate => candidate.Descriptor.AuthorityRoutes)
|
||||||
.Where(route => string.Equals(
|
.Where(route => string.Equals(
|
||||||
route.AuthorityRuntimeId,
|
route.AuthorityRuntimeId,
|
||||||
target.AuthorityRuntimeId,
|
target.AuthorityRuntimeId,
|
||||||
StringComparison.Ordinal))
|
StringComparison.Ordinal))
|
||||||
.Where(route => route.Supports(protocol))
|
.Where(route => route.Supports(protocol))
|
||||||
.GroupBy(route => route.Endpoint, StringComparer.Ordinal)
|
.ToArray();
|
||||||
.Select(group => group.OrderBy(route => route.Priority).First())
|
var verified = new List<CultMeshTransportCandidate>();
|
||||||
.Select(route => new CultMeshTransportCandidate(
|
var rejected = new List<Exception>();
|
||||||
|
foreach (var route in routes)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
verified.Add(new CultMeshTransportCandidate(
|
||||||
route.Endpoint,
|
route.Endpoint,
|
||||||
priority: route.Priority,
|
priority: route.Priority,
|
||||||
authorityRuntimeId: route.AuthorityRuntimeId,
|
authorityRuntimeId: route.AuthorityRuntimeId,
|
||||||
|
|
@ -1102,7 +1107,23 @@ namespace GameCult.Mesh
|
||||||
.WithVerifiedAuthority(_options.Trust.Verify(
|
.WithVerifiedAuthority(_options.Trust.Verify(
|
||||||
target.VerseId,
|
target.VerseId,
|
||||||
route,
|
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)
|
.OrderBy(candidate => candidate.Priority)
|
||||||
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
||||||
.ToArray();
|
.ToArray();
|
||||||
|
|
|
||||||
|
|
@ -207,6 +207,12 @@ namespace GameCult.Mesh
|
||||||
/// </summary>
|
/// </summary>
|
||||||
public Func<ICultNetSchemaClient>? CreateClient { get; set; }
|
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>
|
/// <summary>Gets or sets the clock that owns discovery deadlines.</summary>
|
||||||
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
public ICultMeshClock Clock { get; set; } = CultMeshSystemClock.Instance;
|
||||||
|
|
||||||
|
|
@ -341,7 +347,8 @@ namespace GameCult.Mesh
|
||||||
|
|
||||||
private ICultNetSchemaClient CreateClient(string endpoint)
|
private ICultNetSchemaClient CreateClient(string endpoint)
|
||||||
{
|
{
|
||||||
return _options.CreateClient?.Invoke()
|
return _options.CreateClientForEndpoint?.Invoke(endpoint)
|
||||||
|
?? _options.CreateClient?.Invoke()
|
||||||
?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient);
|
?? CultNetSchemaClients.CreateForEndpoint(endpoint, _options.Security, _options.ConfigureClient);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@ become usable by merely repeating the expected Verse and runtime strings.
|
||||||
|
|
||||||
```csharp
|
```csharp
|
||||||
using GameCult.Mesh;
|
using GameCult.Mesh;
|
||||||
|
using GameCult.Networking.WebSockets;
|
||||||
|
|
||||||
var odinEndpoint = Environment.GetEnvironmentVariable("ODIN_ENDPOINT")
|
var odinEndpoint = Environment.GetEnvironmentVariable("ODIN_ENDPOINT")
|
||||||
?? throw new InvalidOperationException("Set ODIN_ENDPOINT to the configured rendezvous route.");
|
?? 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
|
using var mesh = new CultMeshClient(new CultMeshClientOptions
|
||||||
{
|
{
|
||||||
RendezvousEndpoints = new[] { odinEndpoint },
|
RendezvousEndpoints = new[] { odinEndpoint },
|
||||||
|
Discovery = new CultMeshVerseDiscoveryClientOptions
|
||||||
|
{
|
||||||
|
CreateClientForEndpoint = _ => new CultNetWebSocketSchemaClient()
|
||||||
|
},
|
||||||
Sessions = new CultMeshSessionManagerOptions
|
Sessions = new CultMeshSessionManagerOptions
|
||||||
{
|
{
|
||||||
Trust = new CultMeshAuthorityTrustPolicy(
|
Trust = new CultMeshAuthorityTrustPolicy(
|
||||||
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
||||||
new[] { odinRoot })
|
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
|
routes require a trusted Odin signature, provider nonce proof, certificate
|
||||||
validity, and a protected channel.
|
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
|
Fast session-management check (this uses controlled transports; it is not the
|
||||||
network proof):
|
network proof):
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -134,6 +134,45 @@ public sealed class CultMeshAuthorityProofTests
|
||||||
.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
|
.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) =>
|
private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) =>
|
||||||
CultMeshAuthorityProof.CreateSignedRoute(
|
CultMeshAuthorityProof.CreateSignedRoute(
|
||||||
"aetheria",
|
"aetheria",
|
||||||
|
|
@ -238,4 +277,53 @@ public sealed class CultMeshAuthorityProofTests
|
||||||
}
|
}
|
||||||
public void Dispose() { }
|
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",
|
"name": "org.gamecult.cultlib",
|
||||||
"displayName": "CultLib",
|
"displayName": "CultLib",
|
||||||
"version": "1.0.47",
|
"version": "1.0.48",
|
||||||
"unity": "2021.3",
|
"unity": "2021.3",
|
||||||
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
|
"description": "CultCache, CultNet, and CultMesh managed runtime assemblies for Unity.",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue