Authenticate CultMesh authority routes and replay operations
This commit is contained in:
parent
6835ea4c10
commit
f622ee1b24
30 changed files with 1432 additions and 142 deletions
|
|
@ -15,6 +15,7 @@
|
|||
<Copyright>© Meta Vens and contributors</Copyright>
|
||||
<PublishRepositoryUrl>true</PublishRepositoryUrl>
|
||||
<Version>1.0.33</Version>
|
||||
<Version Condition="'$(CultLibPackageVersion)' != ''">$(CultLibPackageVersion)</Version>
|
||||
|
||||
<!-- Symbols/debugging -->
|
||||
<EmbedUntrackedSources>true</EmbedUntrackedSources>
|
||||
|
|
|
|||
|
|
@ -24,7 +24,32 @@ export interface CultMeshBrowserIdentity {
|
|||
export interface CultMeshBrowserRoute extends CultMeshBrowserIdentity {
|
||||
endpoint: string;
|
||||
protocolId?: string;
|
||||
protocolIds?: readonly string[];
|
||||
priority?: number;
|
||||
generation: string;
|
||||
certificate?: CultMeshBrowserRouteCertificate;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserP256PublicKey {
|
||||
keyId: string;
|
||||
x: string;
|
||||
y: string;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRouteCertificate {
|
||||
providerKey: CultMeshBrowserP256PublicKey;
|
||||
odinKeyId: string;
|
||||
issuedAtUnixMilliseconds: number;
|
||||
expiresAtUnixMilliseconds: number;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export type CultMeshBrowserAuthorityTrustMode = "authenticated-remote" | "local-development";
|
||||
|
||||
export interface CultMeshBrowserAuthorityTrustPolicy {
|
||||
mode: CultMeshBrowserAuthorityTrustMode;
|
||||
odinRoots?: readonly CultMeshBrowserP256PublicKey[];
|
||||
now?: () => number;
|
||||
}
|
||||
|
||||
export interface CultMeshBrowserRendezvous {
|
||||
|
|
@ -162,6 +187,8 @@ export interface CultMeshBrowserClientOptions extends CultMeshBrowserIdentity {
|
|||
requestTimeoutMs?: number;
|
||||
maxFrameBytes?: number;
|
||||
createId?: () => string;
|
||||
/** Consumer-owned authority roots. Remote sessions fail closed when omitted. */
|
||||
trust?: CultMeshBrowserAuthorityTrustPolicy;
|
||||
}
|
||||
|
||||
export interface CultMeshRawDocumentLeaseOptions {
|
||||
|
|
@ -215,6 +242,7 @@ interface PendingSnapshot {
|
|||
}
|
||||
|
||||
interface PendingOperation {
|
||||
request: CultNetOperationRequestMessage;
|
||||
resolve: (response: CultNetOperationResponseMessage) => void;
|
||||
reject: (error: Error) => void;
|
||||
timer: ReturnType<typeof setTimeout>;
|
||||
|
|
@ -297,6 +325,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
maxFrameBytes: options.maxFrameBytes ?? 4 * 1024 * 1024,
|
||||
createId: options.createId ?? (() => crypto.randomUUID()),
|
||||
socketFactory: options.socketFactory ?? (endpoint => new WebSocket(endpoint)),
|
||||
trust: options.trust ?? { mode: "authenticated-remote", odinRoots: [] },
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -369,15 +398,12 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
if (!this.#pendingOperations.delete(messageId)) return;
|
||||
reject(new Error(`CultMesh operation '${messageId}' timed out.`));
|
||||
}, this.#options.requestTimeoutMs);
|
||||
this.#pendingOperations.set(messageId, { resolve, reject, timer });
|
||||
this.#pendingOperations.set(messageId, { request, resolve, reject, timer });
|
||||
});
|
||||
try {
|
||||
this.send(request);
|
||||
} catch (error) {
|
||||
const pending = this.#pendingOperations.get(messageId);
|
||||
if (pending) clearTimeout(pending.timer);
|
||||
this.#pendingOperations.delete(messageId);
|
||||
throw error;
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
return response;
|
||||
}
|
||||
|
|
@ -490,6 +516,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
route.authorityRuntimeId !== this.identity.authorityRuntimeId) {
|
||||
throw new Error("CultMesh rendezvous returned a route for the wrong stable identity.");
|
||||
}
|
||||
await verifyAuthorityRoute(route, this.#options.trust);
|
||||
const endpoint = new URL(route.endpoint);
|
||||
if (endpoint.protocol !== "ws:" && endpoint.protocol !== "wss:") {
|
||||
throw new Error(`CultMesh browser route must use ws:// or wss://, got '${route.endpoint}'.`);
|
||||
|
|
@ -501,6 +528,17 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
let opened = false;
|
||||
let settled = false;
|
||||
const handshakeId = this.#options.createId();
|
||||
const clientNonce = randomNonce();
|
||||
const sessionRequest: CultMeshSessionOpenMessage = {
|
||||
schemaVersion: "cultmesh.session_open.v2",
|
||||
messageId: handshakeId,
|
||||
sourceRuntimeId: this.runtimeId,
|
||||
verseId: this.identity.verseId,
|
||||
authorityRuntimeId: this.identity.authorityRuntimeId,
|
||||
protocolId: route.protocolId ?? "cultmesh.documents.v1",
|
||||
routeGeneration: route.generation,
|
||||
clientNonce,
|
||||
};
|
||||
const finish = (error?: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
|
|
@ -524,8 +562,8 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
runtimeId: this.runtimeId,
|
||||
runtimeKind: "browser",
|
||||
supportedMessageVersions: [
|
||||
"cultmesh.session_open.v1",
|
||||
"cultmesh.session_accepted.v1",
|
||||
"cultmesh.session_open.v2",
|
||||
"cultmesh.session_accepted.v2",
|
||||
"cultnet.database_subscribe.v0",
|
||||
"cultnet.database_unsubscribe.v0",
|
||||
"cultnet.database_change_raw.v0",
|
||||
|
|
@ -533,16 +571,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
"cultnet.operation_response.v0",
|
||||
],
|
||||
}, this.#options.maxFrameBytes);
|
||||
const request: CultMeshSessionOpenMessage = {
|
||||
schemaVersion: "cultmesh.session_open.v1",
|
||||
messageId: handshakeId,
|
||||
sourceRuntimeId: this.runtimeId,
|
||||
verseId: this.identity.verseId,
|
||||
authorityRuntimeId: this.identity.authorityRuntimeId,
|
||||
protocolId: route.protocolId ?? "cultmesh.documents.v1",
|
||||
routeGeneration: route.generation,
|
||||
};
|
||||
sendSocketMessage(socket, request, this.#options.maxFrameBytes);
|
||||
sendSocketMessage(socket, sessionRequest, this.#options.maxFrameBytes);
|
||||
} catch (error) {
|
||||
finish(error instanceof Error ? error : new Error(String(error)));
|
||||
}
|
||||
|
|
@ -553,8 +582,13 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
finish(new Error(message.error));
|
||||
return;
|
||||
}
|
||||
if (message.schemaVersion !== "cultmesh.session_accepted.v1" || message.messageId !== handshakeId) return;
|
||||
finish(validateSessionAcceptance(message, this.identity, route));
|
||||
if (message.schemaVersion !== "cultmesh.session_accepted.v2" || message.messageId !== handshakeId) return;
|
||||
void validateSessionAcceptance(
|
||||
message,
|
||||
sessionRequest,
|
||||
route,
|
||||
this.#options.trust,
|
||||
).then(finish).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(`CultMesh WebSocket could not open '${route.endpoint}'.`));
|
||||
|
|
@ -575,6 +609,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
this.setState("connected");
|
||||
try {
|
||||
await Promise.all([...this.#leases.values()].map(lease => this.subscribe(lease)));
|
||||
for (const pending of this.#pendingOperations.values()) this.send(pending.request);
|
||||
} catch (error) {
|
||||
if (this.#socket === socket) this.#socket = undefined;
|
||||
socket.onclose = null;
|
||||
|
|
@ -591,12 +626,7 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
for (const pending of this.#pendingOperations.values()) {
|
||||
clearTimeout(pending.timer);
|
||||
pending.reject(error);
|
||||
}
|
||||
this.#pendingSnapshots.clear();
|
||||
this.#pendingOperations.clear();
|
||||
this.setState("reconnecting");
|
||||
this.#reconnectTimer = setTimeout(() => {
|
||||
this.#reconnectTimer = undefined;
|
||||
|
|
@ -713,6 +743,15 @@ export class CultMeshBrowserClient implements AsyncDisposable {
|
|||
for (const watcher of this.#stateWatchers) watcher(state);
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.#disposed || this.#reconnectTimer) return;
|
||||
this.setState("reconnecting");
|
||||
this.#reconnectTimer = setTimeout(() => {
|
||||
this.#reconnectTimer = undefined;
|
||||
void this.connectSocket().catch(() => this.scheduleReconnect());
|
||||
}, this.#options.reconnectDelayMs);
|
||||
}
|
||||
|
||||
private throwIfDisposed(): void {
|
||||
if (this.#disposed) throw new Error("CultMesh browser client is disposed.");
|
||||
}
|
||||
|
|
@ -816,6 +855,7 @@ function selectOdinRoute(
|
|||
protocolIds: ["cultmesh.documents.v1"],
|
||||
priority: 0,
|
||||
generation: [verse.compatibility.transportVersion, verse.compatibility.rulesHash, endpoint].join(":"),
|
||||
certificate: undefined,
|
||||
}));
|
||||
const routes = boundRoutes.filter(route =>
|
||||
route.authorityRuntimeId === identity.authorityRuntimeId &&
|
||||
|
|
@ -836,29 +876,171 @@ function selectOdinRoute(
|
|||
...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,
|
||||
} } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function validateSessionAcceptance(
|
||||
async function validateSessionAcceptance(
|
||||
message: CultMeshSessionAcceptedMessage,
|
||||
identity: CultMeshBrowserIdentity,
|
||||
request: CultMeshSessionOpenMessage,
|
||||
route: CultMeshBrowserRoute,
|
||||
): Error | undefined {
|
||||
trust: CultMeshBrowserAuthorityTrustPolicy,
|
||||
): Promise<Error | undefined> {
|
||||
if (!message.accepted) return new Error(message.error ?? "CultMesh authority rejected the session.");
|
||||
const protocolId = route.protocolId ?? "cultmesh.documents.v1";
|
||||
if (message.verseId !== identity.verseId ||
|
||||
message.authorityRuntimeId !== identity.authorityRuntimeId ||
|
||||
if (message.verseId !== request.verseId ||
|
||||
message.authorityRuntimeId !== request.authorityRuntimeId ||
|
||||
message.protocolId !== protocolId ||
|
||||
message.routeGeneration !== route.generation) {
|
||||
message.routeGeneration !== route.generation ||
|
||||
message.clientNonce !== request.clientNonce) {
|
||||
return new Error(
|
||||
`CultMesh route proved '${message.verseId}/${message.authorityRuntimeId}/${message.protocolId}/${message.routeGeneration}', ` +
|
||||
`expected '${identity.verseId}/${identity.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
|
||||
`expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`,
|
||||
);
|
||||
}
|
||||
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined;
|
||||
const providerKey = route.certificate?.providerKey;
|
||||
if (!providerKey || message.providerKeyId !== providerKey.keyId || !message.providerSignature) {
|
||||
return new Error("CultMesh authority did not prove possession of the Odin-certified provider key.");
|
||||
}
|
||||
if (!await verifyP256(providerKey, canonicalSession(request, route.endpoint), message.providerSignature)) {
|
||||
return new Error("CultMesh provider session proof is invalid.");
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
async function verifyAuthorityRoute(
|
||||
route: CultMeshBrowserRoute,
|
||||
trust: CultMeshBrowserAuthorityTrustPolicy,
|
||||
): Promise<void> {
|
||||
const certificate = route.certificate;
|
||||
if (!certificate) {
|
||||
if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return;
|
||||
throw new Error("Remote CultMesh routes require an Odin-signed authority certificate.");
|
||||
}
|
||||
const endpoint = new URL(route.endpoint);
|
||||
if (endpoint.protocol !== "wss:" && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) {
|
||||
throw new Error("Authenticated remote CultMesh browser routes require wss:// channel protection.");
|
||||
}
|
||||
const now = trust.now?.() ?? Date.now();
|
||||
if (now < certificate.issuedAtUnixMilliseconds || now >= certificate.expiresAtUnixMilliseconds) {
|
||||
throw new Error("The Odin route certificate is not currently valid.");
|
||||
}
|
||||
const root = trust.odinRoots?.find(candidate => candidate.keyId === certificate.odinKeyId);
|
||||
if (!root) throw new Error(`Odin key '${certificate.odinKeyId}' is not trusted by this consumer.`);
|
||||
if (!await verifyP256(root, canonicalRoute(route), certificate.signature)) {
|
||||
throw new Error("The Odin route certificate signature is invalid.");
|
||||
}
|
||||
}
|
||||
|
||||
function canonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
|
||||
const certificate = route.certificate!;
|
||||
return canonicalFields(
|
||||
"gamecult.cultmesh.route-certificate.v1",
|
||||
route.verseId,
|
||||
route.authorityRuntimeId,
|
||||
route.endpoint,
|
||||
[...(route.protocolIds ?? [route.protocolId ?? "cultmesh.documents.v1"])].sort().join("\u001f"),
|
||||
String(route.priority ?? 0),
|
||||
route.generation,
|
||||
certificate.providerKey.keyId,
|
||||
certificate.providerKey.x,
|
||||
certificate.providerKey.y,
|
||||
certificate.odinKeyId,
|
||||
String(certificate.issuedAtUnixMilliseconds),
|
||||
String(certificate.expiresAtUnixMilliseconds),
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalSession(request: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
|
||||
return canonicalFields(
|
||||
"gamecult.cultmesh.session-proof.v1",
|
||||
request.clientNonce,
|
||||
request.messageId,
|
||||
request.sourceRuntimeId,
|
||||
request.verseId,
|
||||
request.authorityRuntimeId,
|
||||
request.protocolId,
|
||||
endpoint,
|
||||
request.routeGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
function canonicalFields(...values: string[]): Uint8Array {
|
||||
const encoder = new TextEncoder();
|
||||
const encoded = values.map(value => encoder.encode(value));
|
||||
const total = encoded.reduce((sum, value) => sum + 4 + value.byteLength, 0);
|
||||
const result = new Uint8Array(total);
|
||||
const view = new DataView(result.buffer);
|
||||
let offset = 0;
|
||||
for (const value of encoded) {
|
||||
view.setUint32(offset, value.byteLength, false);
|
||||
offset += 4;
|
||||
result.set(value, offset);
|
||||
offset += value.byteLength;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
async function verifyP256(
|
||||
key: CultMeshBrowserP256PublicKey,
|
||||
payload: Uint8Array,
|
||||
signatureBase64: string,
|
||||
): Promise<boolean> {
|
||||
try {
|
||||
const x = base64ToBytes(key.x);
|
||||
const y = base64ToBytes(key.y);
|
||||
const signature = base64ToBytes(signatureBase64);
|
||||
if (x.byteLength !== 32 || y.byteLength !== 32 || signature.byteLength !== 64) return false;
|
||||
const raw = new Uint8Array(65);
|
||||
raw[0] = 4;
|
||||
raw.set(x, 1);
|
||||
raw.set(y, 33);
|
||||
const publicKey = await crypto.subtle.importKey(
|
||||
"raw",
|
||||
raw.slice().buffer as ArrayBuffer,
|
||||
{ name: "ECDSA", namedCurve: "P-256" },
|
||||
false,
|
||||
["verify"],
|
||||
);
|
||||
return await crypto.subtle.verify(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
publicKey,
|
||||
signature.slice().buffer as ArrayBuffer,
|
||||
payload.slice().buffer as ArrayBuffer,
|
||||
);
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function randomNonce(): string {
|
||||
const bytes = crypto.getRandomValues(new Uint8Array(32));
|
||||
return bytesToBase64(bytes);
|
||||
}
|
||||
|
||||
function isLoopbackEndpoint(value: string): boolean {
|
||||
try {
|
||||
const host = new URL(value).hostname.replace(/^\[|\]$/g, "").toLowerCase();
|
||||
return host === "localhost" || host === "127.0.0.1" || host === "::1";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function requireText(value: string, field: string): void {
|
||||
if (!value || value.trim().length === 0) throw new Error(`CultMesh browser ${field} is required.`);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,9 +19,12 @@ import {
|
|||
decodeCultNetOperationPayload,
|
||||
decodeCultNetPayload,
|
||||
type CultMeshBrowserRoute,
|
||||
type CultMeshBrowserP256PublicKey,
|
||||
type CultMeshBrowserSocket,
|
||||
} from "../src/index.js";
|
||||
|
||||
const localTrust = { mode: "local-development" as const };
|
||||
|
||||
test("Odin rendezvous resolves stable authority-runtime identity and observes route rotation", async () => {
|
||||
const odin = new FakeOdin("ws://127.0.0.1:4050/mesh");
|
||||
let id = 0;
|
||||
|
|
@ -98,6 +101,7 @@ test("browser client leases provider state, invokes an operation, and follows ro
|
|||
verseId: route.verseId,
|
||||
authorityRuntimeId: route.authorityRuntimeId,
|
||||
runtimeId: "browser-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => route },
|
||||
createId: () => `id-${++id}`,
|
||||
socketFactory: endpoint => endpoint === first.route.endpoint ? first.open() : second.open(),
|
||||
|
|
@ -144,6 +148,7 @@ test("browser client rejects wrong rendezvous identity and has no document write
|
|||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "browser-test",
|
||||
trust: localTrust,
|
||||
rendezvous: {
|
||||
resolve: async () => ({
|
||||
...provider.route,
|
||||
|
|
@ -165,6 +170,7 @@ test("browser client rejects a route whose peer proves the wrong authority", asy
|
|||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "browser-peer-proof-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => ({ ...provider.route, authorityRuntimeId: "sample.counter-daemon" }) },
|
||||
socketFactory: () => provider.open(),
|
||||
}),
|
||||
|
|
@ -172,6 +178,56 @@ test("browser client rejects a route whose peer proves the wrong authority", asy
|
|||
);
|
||||
});
|
||||
|
||||
test("browser client accepts an Odin-certified route only after provider nonce proof", async () => {
|
||||
const fixture = await createSignedRoute("wss://provider.example/mesh");
|
||||
const client = await CultMeshBrowserClient.connect({
|
||||
...fixture.route,
|
||||
runtimeId: "browser-authenticated-test",
|
||||
rendezvous: { resolve: async () => fixture.route },
|
||||
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
|
||||
socketFactory: () => signedHandshakeSocket(fixture.route, fixture.providerPrivate),
|
||||
});
|
||||
|
||||
assert.equal(client.state, "connected");
|
||||
await client.dispose();
|
||||
});
|
||||
|
||||
test("browser client rejects a credential-free authority impersonator", async () => {
|
||||
const fixture = await createSignedRoute("wss://provider.example/mesh");
|
||||
const impersonator = new FakeProvider("wss://provider.example/mesh", 1);
|
||||
|
||||
await assert.rejects(
|
||||
CultMeshBrowserClient.connect({
|
||||
...fixture.route,
|
||||
runtimeId: "browser-impersonation-test",
|
||||
rendezvous: { resolve: async () => fixture.route },
|
||||
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic] },
|
||||
socketFactory: () => impersonator.open(),
|
||||
}),
|
||||
/did not prove possession/,
|
||||
);
|
||||
});
|
||||
|
||||
test("browser client rejects a mutated or expired signed route before opening a provider socket", async () => {
|
||||
const fixture = await createSignedRoute("wss://provider.example/mesh");
|
||||
let opened = false;
|
||||
const connect = (route: CultMeshBrowserRoute, now?: () => number) => CultMeshBrowserClient.connect({
|
||||
...route,
|
||||
runtimeId: "browser-route-certificate-test",
|
||||
rendezvous: { resolve: async () => route },
|
||||
trust: { mode: "authenticated-remote", odinRoots: [fixture.odinPublic], now },
|
||||
socketFactory: () => {
|
||||
opened = true;
|
||||
return signedHandshakeSocket(route, fixture.providerPrivate);
|
||||
},
|
||||
});
|
||||
|
||||
await assert.rejects(connect({ ...fixture.route, endpoint: "wss://evil.example/mesh" }), /signature is invalid/);
|
||||
assert.equal(opened, false);
|
||||
await assert.rejects(connect(fixture.route, () => fixture.expiresAt), /not currently valid/);
|
||||
assert.equal(opened, false);
|
||||
});
|
||||
|
||||
test("browser client bounds unanswered provider operations", async () => {
|
||||
const route = {
|
||||
verseId: "sample.counter",
|
||||
|
|
@ -182,6 +238,7 @@ test("browser client bounds unanswered provider operations", async () => {
|
|||
const client = await CultMeshBrowserClient.connect({
|
||||
...route,
|
||||
runtimeId: "browser-timeout-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => route },
|
||||
requestTimeoutMs: 5,
|
||||
socketFactory: () => handshakeOnlySocket(route),
|
||||
|
|
@ -204,6 +261,7 @@ test("browser client rejects a correlated framework failure without waiting for
|
|||
const client = await CultMeshBrowserClient.connect({
|
||||
...provider.route,
|
||||
runtimeId: "browser-failure-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => provider.route },
|
||||
requestTimeoutMs: 60_000,
|
||||
socketFactory: () => provider.open(),
|
||||
|
|
@ -228,6 +286,82 @@ test("browser client rejects a correlated framework failure without waiting for
|
|||
await client.dispose();
|
||||
});
|
||||
|
||||
test("browser client replays one idempotent operation after route loss without duplicating its effect", async () => {
|
||||
const firstRoute = localRoute("ws://127.0.0.1:4311/mesh", "generation-1");
|
||||
const secondRoute = localRoute("ws://127.0.0.1:4312/mesh", "generation-2");
|
||||
let route = firstRoute;
|
||||
let effectCount = 0;
|
||||
let dropFirstResponse = true;
|
||||
const results = new Map<string, CultNetMessage>();
|
||||
const open = () => {
|
||||
let socket: FakeSocket;
|
||||
socket = new FakeSocket(wire => {
|
||||
const message = parseCultNetMessage(decode(wire));
|
||||
if (message.schemaVersion === "cultmesh.session_open.v2") {
|
||||
socket.deliver({
|
||||
schemaVersion: "cultmesh.session_accepted.v2",
|
||||
messageId: message.messageId,
|
||||
accepted: true,
|
||||
verseId: message.verseId,
|
||||
authorityRuntimeId: message.authorityRuntimeId,
|
||||
protocolId: message.protocolId,
|
||||
routeGeneration: message.routeGeneration,
|
||||
clientNonce: message.clientNonce,
|
||||
providerKeyId: "",
|
||||
providerSignature: "",
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.schemaVersion !== "cultnet.operation_request.v0") return;
|
||||
let response = results.get(message.messageId);
|
||||
if (!response) {
|
||||
effectCount++;
|
||||
response = {
|
||||
schemaVersion: "cultnet.operation_response.v0",
|
||||
messageId: message.messageId,
|
||||
serviceId: message.serviceId,
|
||||
operation: message.operation,
|
||||
status: "accepted",
|
||||
payloadSchema: "sample.increment_receipt.v1",
|
||||
payloadEncoding: "messagepack-base64",
|
||||
payload: Buffer.from(encode({ count: effectCount })).toString("base64"),
|
||||
sourceRuntimeId: "sample.counter-daemon",
|
||||
};
|
||||
results.set(message.messageId, response!);
|
||||
}
|
||||
if (dropFirstResponse) {
|
||||
dropFirstResponse = false;
|
||||
route = secondRoute;
|
||||
socket.close(1012, "route lost after commit");
|
||||
return;
|
||||
}
|
||||
socket.deliver(response!);
|
||||
}, () => undefined);
|
||||
return socket;
|
||||
};
|
||||
const client = await CultMeshBrowserClient.connect({
|
||||
...firstRoute,
|
||||
runtimeId: "browser-operation-replay-test",
|
||||
rendezvous: { resolve: async () => route },
|
||||
trust: localTrust,
|
||||
reconnectDelayMs: 1,
|
||||
requestTimeoutMs: 2_000,
|
||||
socketFactory: () => open(),
|
||||
});
|
||||
|
||||
const response = await client.invoke({
|
||||
serviceId: "sample.counter",
|
||||
operation: "increment",
|
||||
payloadSchema: "sample.increment.v1",
|
||||
payload: { amount: 1 },
|
||||
idempotencyKey: "replay-once",
|
||||
});
|
||||
|
||||
assert.deepEqual(decodeCultNetOperationPayload<{ count: number }>(response), { count: 1 });
|
||||
assert.equal(effectCount, 1);
|
||||
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",
|
||||
|
|
@ -239,6 +373,7 @@ test("browser client rejects an operation response from another runtime", async
|
|||
const client = await CultMeshBrowserClient.connect({
|
||||
...provider.route,
|
||||
runtimeId: "browser-response-authority-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => provider.route },
|
||||
socketFactory: () => provider.open(),
|
||||
});
|
||||
|
|
@ -266,6 +401,7 @@ test("browser client rejects oversized outbound schema messages", async () => {
|
|||
CultMeshBrowserClient.connect({
|
||||
...route,
|
||||
runtimeId: "browser-size-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => route },
|
||||
maxFrameBytes: 8,
|
||||
socketFactory: () => new FakeSocket(() => undefined, () => undefined),
|
||||
|
|
@ -279,6 +415,7 @@ test("browser client reserves subscription identity while the initial lease open
|
|||
const client = await CultMeshBrowserClient.connect({
|
||||
...provider.route,
|
||||
runtimeId: "browser-lease-race-test",
|
||||
trust: localTrust,
|
||||
rendezvous: { resolve: async () => provider.route },
|
||||
socketFactory: () => provider.open(),
|
||||
});
|
||||
|
|
@ -344,7 +481,7 @@ class FakeProvider {
|
|||
private receive(socket: FakeSocket, wire: Uint8Array): void {
|
||||
const message = parseCultNetMessage(decode(wire));
|
||||
switch (message.schemaVersion) {
|
||||
case "cultmesh.session_open.v1":
|
||||
case "cultmesh.session_open.v2":
|
||||
this.acceptSession(socket, message);
|
||||
return;
|
||||
case "cultnet.database_subscribe.v0":
|
||||
|
|
@ -363,13 +500,16 @@ class FakeProvider {
|
|||
|
||||
private acceptSession(socket: FakeSocket, message: CultMeshSessionOpenMessage): void {
|
||||
socket.deliver({
|
||||
schemaVersion: "cultmesh.session_accepted.v1",
|
||||
schemaVersion: "cultmesh.session_accepted.v2",
|
||||
messageId: message.messageId,
|
||||
accepted: true,
|
||||
verseId: message.verseId,
|
||||
authorityRuntimeId: this.#runtimeId,
|
||||
protocolId: message.protocolId,
|
||||
routeGeneration: message.routeGeneration,
|
||||
clientNonce: message.clientNonce,
|
||||
providerKeyId: "",
|
||||
providerSignature: "",
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -501,20 +641,35 @@ function handshakeOnlySocket(route: CultMeshBrowserRoute): FakeSocket {
|
|||
let socket: FakeSocket;
|
||||
socket = new FakeSocket(wire => {
|
||||
const message = parseCultNetMessage(decode(wire));
|
||||
if (message.schemaVersion !== "cultmesh.session_open.v1") return;
|
||||
if (message.schemaVersion !== "cultmesh.session_open.v2") return;
|
||||
socket.deliver({
|
||||
schemaVersion: "cultmesh.session_accepted.v1",
|
||||
schemaVersion: "cultmesh.session_accepted.v2",
|
||||
messageId: message.messageId,
|
||||
accepted: true,
|
||||
verseId: route.verseId,
|
||||
authorityRuntimeId: route.authorityRuntimeId,
|
||||
protocolId: message.protocolId,
|
||||
routeGeneration: message.routeGeneration,
|
||||
clientNonce: message.clientNonce,
|
||||
providerKeyId: "",
|
||||
providerSignature: "",
|
||||
});
|
||||
}, () => undefined);
|
||||
return socket;
|
||||
}
|
||||
|
||||
function localRoute(endpoint: string, generation: string): CultMeshBrowserRoute {
|
||||
return {
|
||||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
endpoint,
|
||||
protocolId: "cultmesh.documents.v1",
|
||||
protocolIds: ["cultmesh.documents.v1"],
|
||||
priority: 0,
|
||||
generation,
|
||||
};
|
||||
}
|
||||
|
||||
class FakeSocket implements CultMeshBrowserSocket {
|
||||
binaryType: BinaryType = "arraybuffer";
|
||||
readyState = 0;
|
||||
|
|
@ -555,6 +710,116 @@ class FakeSocket implements CultMeshBrowserSocket {
|
|||
}
|
||||
}
|
||||
|
||||
async function createSignedRoute(endpoint: string): Promise<{
|
||||
route: CultMeshBrowserRoute;
|
||||
odinPublic: CultMeshBrowserP256PublicKey;
|
||||
providerPrivate: CryptoKey;
|
||||
expiresAt: number;
|
||||
}> {
|
||||
const odin = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
||||
const provider = await crypto.subtle.generateKey({ name: "ECDSA", namedCurve: "P-256" }, true, ["sign", "verify"]);
|
||||
const odinPublic = await exportPublic("odin-root-1", odin.publicKey);
|
||||
const providerPublic = await exportPublic("provider-1", provider.publicKey);
|
||||
const issuedAt = Date.now() - 1_000;
|
||||
const expiresAt = Date.now() + 60_000;
|
||||
const unsigned: CultMeshBrowserRoute = {
|
||||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
endpoint,
|
||||
protocolId: "cultmesh.documents.v1",
|
||||
protocolIds: ["cultmesh.documents.v1"],
|
||||
priority: 0,
|
||||
generation: "signed-generation-1",
|
||||
certificate: {
|
||||
providerKey: providerPublic,
|
||||
odinKeyId: odinPublic.keyId,
|
||||
issuedAtUnixMilliseconds: issuedAt,
|
||||
expiresAtUnixMilliseconds: expiresAt,
|
||||
signature: "",
|
||||
},
|
||||
};
|
||||
const signature = await crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
odin.privateKey,
|
||||
testCanonicalRoute(unsigned).slice().buffer as ArrayBuffer,
|
||||
);
|
||||
return {
|
||||
route: { ...unsigned, certificate: { ...unsigned.certificate!, signature: toBase64(new Uint8Array(signature)) } },
|
||||
odinPublic,
|
||||
providerPrivate: provider.privateKey,
|
||||
expiresAt,
|
||||
};
|
||||
}
|
||||
|
||||
function signedHandshakeSocket(route: CultMeshBrowserRoute, providerPrivate: CryptoKey): FakeSocket {
|
||||
let socket: FakeSocket;
|
||||
socket = new FakeSocket(wire => {
|
||||
const message = parseCultNetMessage(decode(wire));
|
||||
if (message.schemaVersion !== "cultmesh.session_open.v2") return;
|
||||
void crypto.subtle.sign(
|
||||
{ name: "ECDSA", hash: "SHA-256" },
|
||||
providerPrivate,
|
||||
testCanonicalSession(message, route.endpoint).slice().buffer as ArrayBuffer,
|
||||
).then(signature => socket.deliver({
|
||||
schemaVersion: "cultmesh.session_accepted.v2",
|
||||
messageId: message.messageId,
|
||||
accepted: true,
|
||||
verseId: message.verseId,
|
||||
authorityRuntimeId: message.authorityRuntimeId,
|
||||
protocolId: message.protocolId,
|
||||
routeGeneration: message.routeGeneration,
|
||||
clientNonce: message.clientNonce,
|
||||
providerKeyId: route.certificate!.providerKey.keyId,
|
||||
providerSignature: toBase64(new Uint8Array(signature)),
|
||||
}));
|
||||
}, () => undefined);
|
||||
return socket;
|
||||
}
|
||||
|
||||
async function exportPublic(keyId: string, key: CryptoKey): Promise<CultMeshBrowserP256PublicKey> {
|
||||
const jwk = await crypto.subtle.exportKey("jwk", key);
|
||||
return { keyId, x: urlBase64ToBase64(jwk.x!), y: urlBase64ToBase64(jwk.y!) };
|
||||
}
|
||||
|
||||
function testCanonicalRoute(route: CultMeshBrowserRoute): Uint8Array {
|
||||
const certificate = route.certificate!;
|
||||
return testCanonical(
|
||||
"gamecult.cultmesh.route-certificate.v1", route.verseId, route.authorityRuntimeId, route.endpoint,
|
||||
[...route.protocolIds!].sort().join("\u001f"), String(route.priority), route.generation,
|
||||
certificate.providerKey.keyId, certificate.providerKey.x, certificate.providerKey.y,
|
||||
certificate.odinKeyId, String(certificate.issuedAtUnixMilliseconds), String(certificate.expiresAtUnixMilliseconds),
|
||||
);
|
||||
}
|
||||
|
||||
function testCanonicalSession(message: CultMeshSessionOpenMessage, endpoint: string): Uint8Array {
|
||||
return testCanonical(
|
||||
"gamecult.cultmesh.session-proof.v1", message.clientNonce, message.messageId, message.sourceRuntimeId,
|
||||
message.verseId, message.authorityRuntimeId, message.protocolId, endpoint, message.routeGeneration,
|
||||
);
|
||||
}
|
||||
|
||||
function testCanonical(...values: string[]): Uint8Array {
|
||||
const chunks = values.map(value => new TextEncoder().encode(value));
|
||||
const bytes = new Uint8Array(chunks.reduce((sum, chunk) => sum + 4 + chunk.length, 0));
|
||||
const view = new DataView(bytes.buffer);
|
||||
let offset = 0;
|
||||
for (const chunk of chunks) {
|
||||
view.setUint32(offset, chunk.length, false);
|
||||
offset += 4;
|
||||
bytes.set(chunk, offset);
|
||||
offset += chunk.length;
|
||||
}
|
||||
return bytes;
|
||||
}
|
||||
|
||||
function toBase64(bytes: Uint8Array): string {
|
||||
return Buffer.from(bytes).toString("base64");
|
||||
}
|
||||
|
||||
function urlBase64ToBase64(value: string): string {
|
||||
return Buffer.from(value, "base64url").toString("base64");
|
||||
}
|
||||
|
||||
function bytesToBase64(bytes: Uint8Array): string {
|
||||
let binary = "";
|
||||
for (const byte of bytes) binary += String.fromCharCode(byte);
|
||||
|
|
|
|||
|
|
@ -3,16 +3,19 @@
|
|||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-accepted.schema.json",
|
||||
"title": "CultMesh Session Accepted",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration"],
|
||||
"required": ["schemaVersion", "messageId", "accepted", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce", "providerKeyId", "providerSignature"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultmesh.session_accepted.v1" },
|
||||
"schemaVersion": { "const": "cultmesh.session_accepted.v2" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"accepted": { "type": "boolean" },
|
||||
"verseId": { "type": "string", "minLength": 1 },
|
||||
"authorityRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"protocolId": { "type": "string", "minLength": 1 },
|
||||
"routeGeneration": { "type": "string" },
|
||||
"clientNonce": { "type": "string" },
|
||||
"providerKeyId": { "type": "string" },
|
||||
"providerSignature": { "type": "string" },
|
||||
"error": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,15 +3,16 @@
|
|||
"$id": "https://github.com/GameCult/cultnet-ts/contracts/cultmesh.session-open.schema.json",
|
||||
"title": "CultMesh Session Open",
|
||||
"type": "object",
|
||||
"required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration"],
|
||||
"required": ["schemaVersion", "messageId", "sourceRuntimeId", "verseId", "authorityRuntimeId", "protocolId", "routeGeneration", "clientNonce"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"schemaVersion": { "const": "cultmesh.session_open.v1" },
|
||||
"schemaVersion": { "const": "cultmesh.session_open.v2" },
|
||||
"messageId": { "type": "string", "minLength": 1 },
|
||||
"sourceRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"verseId": { "type": "string", "minLength": 1 },
|
||||
"authorityRuntimeId": { "type": "string", "minLength": 1 },
|
||||
"protocolId": { "type": "string", "minLength": 1 },
|
||||
"routeGeneration": { "type": "string" }
|
||||
"routeGeneration": { "type": "string" },
|
||||
"clientNonce": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,22 @@
|
|||
"endpoint": { "type": "string", "minLength": 1 },
|
||||
"protocolIds": { "type": "array", "items": { "type": "string", "minLength": 1 }, "uniqueItems": true },
|
||||
"priority": { "type": "integer", "minimum": 0 },
|
||||
"generation": { "type": "string", "minLength": 1 }
|
||||
"generation": { "type": "string", "minLength": 1 },
|
||||
"certificate": { "$ref": "#/$defs/routeCertificate" }
|
||||
}
|
||||
},
|
||||
"routeCertificate": {
|
||||
"type": "object",
|
||||
"required": ["providerKeyId", "providerPublicKeyX", "providerPublicKeyY", "odinKeyId", "issuedAtUnixMilliseconds", "expiresAtUnixMilliseconds", "signature"],
|
||||
"additionalProperties": false,
|
||||
"properties": {
|
||||
"providerKeyId": { "type": "string", "minLength": 1 },
|
||||
"providerPublicKeyX": { "type": "string", "minLength": 1 },
|
||||
"providerPublicKeyY": { "type": "string", "minLength": 1 },
|
||||
"odinKeyId": { "type": "string", "minLength": 1 },
|
||||
"issuedAtUnixMilliseconds": { "type": "integer", "minimum": 0 },
|
||||
"expiresAtUnixMilliseconds": { "type": "integer", "minimum": 1 },
|
||||
"signature": { "type": "string", "minLength": 1 }
|
||||
}
|
||||
},
|
||||
"verse": {
|
||||
|
|
|
|||
|
|
@ -62,8 +62,8 @@ export type CultNetSchemaVersion =
|
|||
| "cultnet.operation_response.v0"
|
||||
| "cultmesh.verse_catalog_request.v0"
|
||||
| "cultmesh.verse_catalog_response.v0"
|
||||
| "cultmesh.session_open.v1"
|
||||
| "cultmesh.session_accepted.v1";
|
||||
| "cultmesh.session_open.v2"
|
||||
| "cultmesh.session_accepted.v2";
|
||||
|
||||
export type CultNetSchemaKind = "wire_message" | "document_payload" | "shared_contract";
|
||||
export type CultNetRawPayloadEncoding = "messagepack";
|
||||
|
|
@ -365,26 +365,41 @@ export interface CultMeshAuthorityRouteMessage {
|
|||
protocolIds: string[];
|
||||
priority: number;
|
||||
generation: string;
|
||||
certificate?: CultMeshRouteCertificateMessage;
|
||||
}
|
||||
|
||||
export interface CultMeshRouteCertificateMessage {
|
||||
providerKeyId: string;
|
||||
providerPublicKeyX: string;
|
||||
providerPublicKeyY: string;
|
||||
odinKeyId: string;
|
||||
issuedAtUnixMilliseconds: number;
|
||||
expiresAtUnixMilliseconds: number;
|
||||
signature: string;
|
||||
}
|
||||
|
||||
export interface CultMeshSessionOpenMessage {
|
||||
schemaVersion: "cultmesh.session_open.v1";
|
||||
schemaVersion: "cultmesh.session_open.v2";
|
||||
messageId: string;
|
||||
sourceRuntimeId: string;
|
||||
verseId: string;
|
||||
authorityRuntimeId: string;
|
||||
protocolId: string;
|
||||
routeGeneration: string;
|
||||
clientNonce: string;
|
||||
}
|
||||
|
||||
export interface CultMeshSessionAcceptedMessage {
|
||||
schemaVersion: "cultmesh.session_accepted.v1";
|
||||
schemaVersion: "cultmesh.session_accepted.v2";
|
||||
messageId: string;
|
||||
accepted: boolean;
|
||||
verseId: string;
|
||||
authorityRuntimeId: string;
|
||||
protocolId: string;
|
||||
routeGeneration: string;
|
||||
clientNonce: string;
|
||||
providerKeyId: string;
|
||||
providerSignature: string;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
|
|
@ -1236,7 +1251,7 @@ function normalizeCultNetOptionalNulls(
|
|||
}
|
||||
}
|
||||
return;
|
||||
case "cultmesh.session_accepted.v1":
|
||||
case "cultmesh.session_accepted.v2":
|
||||
stripNullProperties(candidate, ["error"]);
|
||||
return;
|
||||
case "cultnet.schema_catalog_request.v0":
|
||||
|
|
|
|||
|
|
@ -97,15 +97,18 @@ test("CultNet validates the canonical CultMesh Verse catalog messages", () => {
|
|||
assert.equal(response.verses[0].authorityRoutes![0].generation, "provider-route-1");
|
||||
|
||||
const session = parseCultNetMessage({
|
||||
schemaVersion: "cultmesh.session_accepted.v1",
|
||||
schemaVersion: "cultmesh.session_accepted.v2",
|
||||
messageId: "session-1",
|
||||
accepted: true,
|
||||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-provider",
|
||||
protocolId: "cultmesh.documents.v1",
|
||||
routeGeneration: "provider-route-1",
|
||||
clientNonce: "bm9uY2U=",
|
||||
providerKeyId: "provider-key-1",
|
||||
providerSignature: "c2lnbmF0dXJl",
|
||||
});
|
||||
assert.equal(session.schemaVersion, "cultmesh.session_accepted.v1");
|
||||
assert.equal(session.schemaVersion, "cultmesh.session_accepted.v2");
|
||||
|
||||
assert.throws(() => parseCultNetMessage({
|
||||
...response,
|
||||
|
|
|
|||
|
|
@ -198,6 +198,10 @@ static async Task RunHeadlessAsync(Args arguments)
|
|||
CreateClient = CreateClient,
|
||||
TransportVersion = arguments.TransportVersion
|
||||
},
|
||||
Sessions = new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
|
||||
},
|
||||
Connectors =
|
||||
[
|
||||
new CultMeshUriSchemaTransportConnector(
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ try {
|
|||
verseId: "sample.counter",
|
||||
authorityRuntimeId: "sample.counter-daemon",
|
||||
runtimeId: "sample.chromium",
|
||||
trust: { mode: "local-development" },
|
||||
rendezvous: new CultMeshBrowserOdinRendezvous({
|
||||
endpoints: [odinEndpoint],
|
||||
runtimeId: "sample.chromium.discovery",
|
||||
|
|
|
|||
|
|
@ -34,7 +34,7 @@ foreach ($project in $projects) {
|
|||
"--nologo", "--verbosity", "quiet", "-p:NoWarn=1591%3BCS8632"
|
||||
)
|
||||
if (-not [string]::IsNullOrWhiteSpace($PackageVersion)) {
|
||||
$packArguments += "-p:Version=$PackageVersion"
|
||||
$packArguments += "-p:CultLibPackageVersion=$PackageVersion"
|
||||
}
|
||||
& dotnet @packArguments
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
|
|
|
|||
349
src/GameCult.Mesh/CultMeshAuthorityProof.cs
Normal file
349
src/GameCult.Mesh/CultMeshAuthorityProof.cs
Normal file
|
|
@ -0,0 +1,349 @@
|
|||
using System;
|
||||
using System.Buffers.Binary;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Text;
|
||||
using GameCult.Networking;
|
||||
|
||||
namespace GameCult.Mesh
|
||||
{
|
||||
public enum CultMeshAuthorityTrustMode
|
||||
{
|
||||
AuthenticatedRemote,
|
||||
LocalDevelopment
|
||||
}
|
||||
|
||||
public sealed class CultMeshEcdsaP256PublicKey
|
||||
{
|
||||
public CultMeshEcdsaP256PublicKey(string keyId, string x, string y)
|
||||
{
|
||||
KeyId = Require(keyId, nameof(keyId));
|
||||
X = RequireCoordinate(x, nameof(x));
|
||||
Y = RequireCoordinate(y, nameof(y));
|
||||
}
|
||||
|
||||
public string KeyId { get; }
|
||||
public string X { get; }
|
||||
public string Y { get; }
|
||||
|
||||
internal ECDsa CreateVerifier()
|
||||
{
|
||||
var verifier = ECDsa.Create();
|
||||
verifier.ImportParameters(new ECParameters
|
||||
{
|
||||
Curve = ECCurve.NamedCurves.nistP256,
|
||||
Q = new ECPoint { X = Convert.FromBase64String(X), Y = Convert.FromBase64String(Y) }
|
||||
});
|
||||
return verifier;
|
||||
}
|
||||
|
||||
public static CultMeshEcdsaP256PublicKey From(string keyId, ECDsa key)
|
||||
{
|
||||
if (key == null) throw new ArgumentNullException(nameof(key));
|
||||
var parameters = key.ExportParameters(false);
|
||||
return new CultMeshEcdsaP256PublicKey(
|
||||
keyId,
|
||||
Convert.ToBase64String(parameters.Q.X ?? throw new InvalidOperationException("P-256 key has no X coordinate.")),
|
||||
Convert.ToBase64String(parameters.Q.Y ?? throw new InvalidOperationException("P-256 key has no Y coordinate.")));
|
||||
}
|
||||
|
||||
private static string RequireCoordinate(string value, string name)
|
||||
{
|
||||
var required = Require(value, name);
|
||||
byte[] bytes;
|
||||
try { bytes = Convert.FromBase64String(required); }
|
||||
catch (FormatException error) { throw new ArgumentException("P-256 coordinates must be base64.", name, error); }
|
||||
if (bytes.Length != 32) throw new ArgumentException("P-256 coordinates must contain 32 bytes.", name);
|
||||
return required;
|
||||
}
|
||||
|
||||
private static string Require(string value, string name) =>
|
||||
string.IsNullOrWhiteSpace(value) ? throw new ArgumentException("Value must be non-empty.", name) : value.Trim();
|
||||
}
|
||||
|
||||
public sealed class CultMeshRouteCertificate
|
||||
{
|
||||
public CultMeshRouteCertificate(
|
||||
CultMeshEcdsaP256PublicKey providerKey,
|
||||
string odinKeyId,
|
||||
long issuedAtUnixMilliseconds,
|
||||
long expiresAtUnixMilliseconds,
|
||||
string signature)
|
||||
{
|
||||
ProviderKey = providerKey ?? throw new ArgumentNullException(nameof(providerKey));
|
||||
OdinKeyId = string.IsNullOrWhiteSpace(odinKeyId)
|
||||
? throw new ArgumentException("Odin key identity is required.", nameof(odinKeyId))
|
||||
: odinKeyId.Trim();
|
||||
if (issuedAtUnixMilliseconds < 0) throw new ArgumentOutOfRangeException(nameof(issuedAtUnixMilliseconds));
|
||||
if (expiresAtUnixMilliseconds <= issuedAtUnixMilliseconds) throw new ArgumentOutOfRangeException(nameof(expiresAtUnixMilliseconds));
|
||||
IssuedAtUnixMilliseconds = issuedAtUnixMilliseconds;
|
||||
ExpiresAtUnixMilliseconds = expiresAtUnixMilliseconds;
|
||||
Signature = signature?.Trim() ?? string.Empty;
|
||||
}
|
||||
|
||||
public CultMeshEcdsaP256PublicKey ProviderKey { get; }
|
||||
public string OdinKeyId { get; }
|
||||
public long IssuedAtUnixMilliseconds { get; }
|
||||
public long ExpiresAtUnixMilliseconds { get; }
|
||||
public string Signature { get; }
|
||||
}
|
||||
|
||||
public sealed class CultMeshAuthorityTrustPolicy
|
||||
{
|
||||
private readonly Dictionary<string, CultMeshEcdsaP256PublicKey> _odinRoots;
|
||||
|
||||
public CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode mode,
|
||||
IEnumerable<CultMeshEcdsaP256PublicKey>? odinRoots = null)
|
||||
{
|
||||
Mode = mode;
|
||||
_odinRoots = (odinRoots ?? Array.Empty<CultMeshEcdsaP256PublicKey>())
|
||||
.ToDictionary(key => key.KeyId, StringComparer.Ordinal);
|
||||
}
|
||||
|
||||
public CultMeshAuthorityTrustMode Mode { get; }
|
||||
public IReadOnlyCollection<CultMeshEcdsaP256PublicKey> OdinRoots => _odinRoots.Values;
|
||||
|
||||
/// <summary>Fails closed unless the route is valid under this consumer-owned policy.</summary>
|
||||
public void Validate(string verseId, CultMeshAuthorityRoute route, DateTimeOffset now) =>
|
||||
Verify(verseId, route, now);
|
||||
|
||||
internal CultMeshVerifiedAuthorityRoute Verify(
|
||||
string verseId,
|
||||
CultMeshAuthorityRoute route,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (route == null) throw new ArgumentNullException(nameof(route));
|
||||
var certificate = route.Certificate;
|
||||
if (certificate == null || string.IsNullOrWhiteSpace(certificate.Signature))
|
||||
{
|
||||
if (Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint))
|
||||
return CultMeshVerifiedAuthorityRoute.Local(route);
|
||||
throw Failure(route, "Remote CultMesh routes require an Odin-signed authority certificate.");
|
||||
}
|
||||
if (!IsProtected(route.Endpoint) &&
|
||||
!(Mode == CultMeshAuthorityTrustMode.LocalDevelopment && IsLoopback(route.Endpoint)))
|
||||
throw Failure(route, "Authenticated remote CultMesh routes require TLS or QUIC channel protection.");
|
||||
if (!_odinRoots.TryGetValue(certificate.OdinKeyId, out var root))
|
||||
throw Failure(route, $"Odin key '{certificate.OdinKeyId}' is not trusted by this consumer.");
|
||||
var nowMs = now.ToUnixTimeMilliseconds();
|
||||
if (nowMs < certificate.IssuedAtUnixMilliseconds || nowMs >= certificate.ExpiresAtUnixMilliseconds)
|
||||
throw Failure(route, "The Odin route certificate is not currently valid.");
|
||||
byte[] signature;
|
||||
try { signature = Convert.FromBase64String(certificate.Signature); }
|
||||
catch (FormatException error) { throw Failure(route, "The Odin route signature is not base64.", error); }
|
||||
if (signature.Length != 64) throw Failure(route, "The Odin route signature is not IEEE P1363 P-256.");
|
||||
using var verifier = root.CreateVerifier();
|
||||
if (!verifier.VerifyData(
|
||||
CultMeshAuthorityProof.CanonicalRoute(verseId, route),
|
||||
signature,
|
||||
HashAlgorithmName.SHA256))
|
||||
throw Failure(route, "The Odin route certificate signature is invalid.");
|
||||
return CultMeshVerifiedAuthorityRoute.Authenticated(route, certificate.ProviderKey);
|
||||
}
|
||||
|
||||
private static bool IsLoopback(string endpoint)
|
||||
{
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false;
|
||||
return uri.IsLoopback ||
|
||||
string.Equals(uri.Host, "localhost", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(uri.Host, "127.0.0.1", StringComparison.Ordinal) ||
|
||||
string.Equals(uri.Host, "::1", StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
private static bool IsProtected(string endpoint)
|
||||
{
|
||||
if (!Uri.TryCreate(endpoint, UriKind.Absolute, out var uri)) return false;
|
||||
return string.Equals(uri.Scheme, "wss", StringComparison.OrdinalIgnoreCase) ||
|
||||
string.Equals(uri.Scheme, "https", StringComparison.OrdinalIgnoreCase) ||
|
||||
uri.Scheme.IndexOf("quic", StringComparison.OrdinalIgnoreCase) >= 0;
|
||||
}
|
||||
|
||||
private static CultMeshSessionException Failure(CultMeshAuthorityRoute route, string message, Exception? inner = null) =>
|
||||
new CultMeshSessionException(new CultMeshSessionFailure(
|
||||
CultMeshSessionFailureReason.Authentication,
|
||||
message,
|
||||
route.Endpoint), inner);
|
||||
}
|
||||
|
||||
internal sealed class CultMeshVerifiedAuthorityRoute
|
||||
{
|
||||
private CultMeshVerifiedAuthorityRoute(
|
||||
CultMeshAuthorityRoute route,
|
||||
CultMeshEcdsaP256PublicKey? providerKey,
|
||||
bool localDevelopment)
|
||||
{
|
||||
Route = route;
|
||||
ProviderKey = providerKey;
|
||||
IsLocalDevelopment = localDevelopment;
|
||||
}
|
||||
|
||||
public CultMeshAuthorityRoute Route { get; }
|
||||
public CultMeshEcdsaP256PublicKey? ProviderKey { get; }
|
||||
public bool IsLocalDevelopment { get; }
|
||||
public static CultMeshVerifiedAuthorityRoute Authenticated(CultMeshAuthorityRoute route, CultMeshEcdsaP256PublicKey key) => new(route, key, false);
|
||||
public static CultMeshVerifiedAuthorityRoute Local(CultMeshAuthorityRoute route) => new(route, null, true);
|
||||
}
|
||||
|
||||
public sealed class CultMeshSessionProofSigner
|
||||
{
|
||||
private readonly ECDsa _key;
|
||||
|
||||
public CultMeshSessionProofSigner(CultMeshAuthorityRoute route, ECDsa providerPrivateKey)
|
||||
{
|
||||
Route = route ?? throw new ArgumentNullException(nameof(route));
|
||||
_key = providerPrivateKey ?? throw new ArgumentNullException(nameof(providerPrivateKey));
|
||||
var certificate = route.Certificate ?? throw new ArgumentException("A session proof signer requires a certified route.", nameof(route));
|
||||
ProviderKeyId = certificate.ProviderKey.KeyId;
|
||||
var exported = CultMeshEcdsaP256PublicKey.From(ProviderKeyId, _key);
|
||||
if (!string.Equals(exported.X, certificate.ProviderKey.X, StringComparison.Ordinal) ||
|
||||
!string.Equals(exported.Y, certificate.ProviderKey.Y, StringComparison.Ordinal))
|
||||
throw new ArgumentException("Provider private key does not match the certified route key.", nameof(providerPrivateKey));
|
||||
}
|
||||
|
||||
public CultMeshAuthorityRoute Route { get; }
|
||||
public string ProviderKeyId { get; }
|
||||
|
||||
public string Sign(CultMeshSessionOpenMessage request)
|
||||
{
|
||||
var bytes = CultMeshAuthorityProof.CanonicalSession(request, Route.Endpoint);
|
||||
return Convert.ToBase64String(CultMeshAuthorityProof.SignP256(_key, bytes));
|
||||
}
|
||||
}
|
||||
|
||||
public static class CultMeshAuthorityProof
|
||||
{
|
||||
private const string RouteDomain = "gamecult.cultmesh.route-certificate.v1";
|
||||
private const string SessionDomain = "gamecult.cultmesh.session-proof.v1";
|
||||
|
||||
public static CultMeshAuthorityRoute CreateSignedRoute(
|
||||
string verseId,
|
||||
string authorityRuntimeId,
|
||||
string endpoint,
|
||||
IEnumerable<string> protocolIds,
|
||||
int priority,
|
||||
string generation,
|
||||
CultMeshEcdsaP256PublicKey providerKey,
|
||||
string odinKeyId,
|
||||
DateTimeOffset issuedAt,
|
||||
DateTimeOffset expiresAt,
|
||||
ECDsa odinPrivateKey)
|
||||
{
|
||||
if (odinPrivateKey == null) throw new ArgumentNullException(nameof(odinPrivateKey));
|
||||
var unsignedCertificate = new CultMeshRouteCertificate(
|
||||
providerKey,
|
||||
odinKeyId,
|
||||
issuedAt.ToUnixTimeMilliseconds(),
|
||||
expiresAt.ToUnixTimeMilliseconds(),
|
||||
string.Empty);
|
||||
var unsignedRoute = new CultMeshAuthorityRoute(
|
||||
authorityRuntimeId, endpoint, protocolIds, priority, generation, unsignedCertificate);
|
||||
var signature = Convert.ToBase64String(SignP256(
|
||||
odinPrivateKey,
|
||||
CanonicalRoute(verseId, unsignedRoute)));
|
||||
return new CultMeshAuthorityRoute(
|
||||
authorityRuntimeId,
|
||||
endpoint,
|
||||
protocolIds,
|
||||
priority,
|
||||
generation,
|
||||
new CultMeshRouteCertificate(
|
||||
providerKey,
|
||||
odinKeyId,
|
||||
issuedAt.ToUnixTimeMilliseconds(),
|
||||
expiresAt.ToUnixTimeMilliseconds(),
|
||||
signature));
|
||||
}
|
||||
|
||||
/// <summary>Verifies both the Odin route certificate and provider nonce proof.</summary>
|
||||
public static bool VerifySessionProof(
|
||||
CultMeshSessionOpenMessage request,
|
||||
CultMeshSessionAcceptedMessage response,
|
||||
string verseId,
|
||||
CultMeshAuthorityRoute route,
|
||||
CultMeshAuthorityTrustPolicy trust,
|
||||
DateTimeOffset now)
|
||||
{
|
||||
if (request == null) throw new ArgumentNullException(nameof(request));
|
||||
if (response == null) throw new ArgumentNullException(nameof(response));
|
||||
if (trust == null) throw new ArgumentNullException(nameof(trust));
|
||||
return VerifySession(request, response, trust.Verify(verseId, route, now));
|
||||
}
|
||||
|
||||
internal static byte[] CanonicalRoute(string verseId, CultMeshAuthorityRoute route)
|
||||
{
|
||||
var certificate = route.Certificate ?? throw new InvalidOperationException("Route certificate is required.");
|
||||
return Canonical(
|
||||
RouteDomain,
|
||||
verseId,
|
||||
route.AuthorityRuntimeId,
|
||||
route.Endpoint,
|
||||
string.Join("\u001f", route.ProtocolIds.OrderBy(value => value, StringComparer.Ordinal)),
|
||||
route.Priority.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
route.Generation,
|
||||
certificate.ProviderKey.KeyId,
|
||||
certificate.ProviderKey.X,
|
||||
certificate.ProviderKey.Y,
|
||||
certificate.OdinKeyId,
|
||||
certificate.IssuedAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture),
|
||||
certificate.ExpiresAtUnixMilliseconds.ToString(System.Globalization.CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
internal static byte[] CanonicalSession(CultMeshSessionOpenMessage request, string endpoint) => Canonical(
|
||||
SessionDomain,
|
||||
request.ClientNonce ?? string.Empty,
|
||||
request.MessageId ?? string.Empty,
|
||||
request.SourceRuntimeId ?? string.Empty,
|
||||
request.VerseId ?? string.Empty,
|
||||
request.AuthorityRuntimeId ?? string.Empty,
|
||||
request.ProtocolId ?? string.Empty,
|
||||
endpoint ?? string.Empty,
|
||||
request.RouteGeneration ?? string.Empty);
|
||||
|
||||
internal static bool VerifySession(
|
||||
CultMeshSessionOpenMessage request,
|
||||
CultMeshSessionAcceptedMessage response,
|
||||
CultMeshVerifiedAuthorityRoute route)
|
||||
{
|
||||
if (route.IsLocalDevelopment) return true;
|
||||
if (route.ProviderKey == null ||
|
||||
!string.Equals(response.ProviderKeyId, route.ProviderKey.KeyId, StringComparison.Ordinal) ||
|
||||
!string.Equals(response.ClientNonce, request.ClientNonce, StringComparison.Ordinal) ||
|
||||
string.IsNullOrWhiteSpace(response.ProviderSignature))
|
||||
return false;
|
||||
byte[] signature;
|
||||
try { signature = Convert.FromBase64String(response.ProviderSignature); }
|
||||
catch (FormatException) { return false; }
|
||||
if (signature.Length != 64) return false;
|
||||
using var verifier = route.ProviderKey.CreateVerifier();
|
||||
return verifier.VerifyData(
|
||||
CanonicalSession(request, route.Route.Endpoint),
|
||||
signature,
|
||||
HashAlgorithmName.SHA256);
|
||||
}
|
||||
|
||||
internal static byte[] SignP256(ECDsa key, byte[] payload)
|
||||
{
|
||||
var signature = key.SignData(payload, HashAlgorithmName.SHA256);
|
||||
if (signature.Length != 64)
|
||||
throw new CryptographicException("ECDSA provider did not emit IEEE P1363 P-256 signature bytes.");
|
||||
return signature;
|
||||
}
|
||||
|
||||
private static byte[] Canonical(params string[] values)
|
||||
{
|
||||
using var stream = new MemoryStream();
|
||||
var length = new byte[4];
|
||||
foreach (var value in values)
|
||||
{
|
||||
var bytes = Encoding.UTF8.GetBytes(value ?? string.Empty);
|
||||
BinaryPrimitives.WriteInt32BigEndian(length, bytes.Length);
|
||||
stream.Write(length, 0, length.Length);
|
||||
stream.Write(bytes, 0, bytes.Length);
|
||||
}
|
||||
return stream.ToArray();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -136,7 +136,9 @@ namespace GameCult.Mesh
|
|||
"cultmesh-content-client",
|
||||
target,
|
||||
CultMeshProtocols.Content,
|
||||
candidate.Generation,
|
||||
candidate,
|
||||
null,
|
||||
_options.Clock.UtcNow,
|
||||
_options.ResponseTimeout,
|
||||
cancellationToken).ConfigureAwait(false);
|
||||
return new LegacyRudpContentTransport(
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using GameCult.Networking;
|
||||
|
|
@ -68,6 +69,7 @@ namespace GameCult.Mesh
|
|||
private readonly HashSet<string> _verseIds;
|
||||
private readonly HashSet<string> _protocolIds;
|
||||
private readonly HashSet<string> _routeGenerations;
|
||||
private readonly Dictionary<string, CultMeshSessionProofSigner> _proofSigners;
|
||||
private readonly Func<CultMeshSessionOpenMessage, ICultNetSchemaServerPeer, Task> _handler;
|
||||
private bool _disposed;
|
||||
|
||||
|
|
@ -76,13 +78,16 @@ namespace GameCult.Mesh
|
|||
string authorityRuntimeId,
|
||||
IEnumerable<string> verseIds,
|
||||
IEnumerable<string> protocolIds,
|
||||
IEnumerable<string> routeGenerations)
|
||||
IEnumerable<string> routeGenerations,
|
||||
IEnumerable<CultMeshSessionProofSigner>? proofSigners = null)
|
||||
{
|
||||
_server = server ?? throw new ArgumentNullException(nameof(server));
|
||||
_authorityRuntimeId = Require(authorityRuntimeId, nameof(authorityRuntimeId));
|
||||
_verseIds = Clean(verseIds, nameof(verseIds));
|
||||
_protocolIds = Clean(protocolIds, nameof(protocolIds));
|
||||
_routeGenerations = Clean(routeGenerations, nameof(routeGenerations));
|
||||
_proofSigners = (proofSigners ?? Array.Empty<CultMeshSessionProofSigner>())
|
||||
.ToDictionary(signer => signer.Route.Generation, StringComparer.Ordinal);
|
||||
_handler = HandleAsync;
|
||||
_server.OnCultNet(_handler);
|
||||
}
|
||||
|
|
@ -97,6 +102,7 @@ namespace GameCult.Mesh
|
|||
private Task HandleAsync(CultMeshSessionOpenMessage request, ICultNetSchemaServerPeer peer)
|
||||
{
|
||||
var error = Validate(request);
|
||||
_proofSigners.TryGetValue(request.RouteGeneration ?? string.Empty, out var signer);
|
||||
peer.SendCultNet(new CultMeshSessionAcceptedMessage
|
||||
{
|
||||
MessageId = request.MessageId ?? string.Empty,
|
||||
|
|
@ -105,6 +111,9 @@ namespace GameCult.Mesh
|
|||
AuthorityRuntimeId = _authorityRuntimeId,
|
||||
ProtocolId = request.ProtocolId ?? string.Empty,
|
||||
RouteGeneration = request.RouteGeneration ?? string.Empty,
|
||||
ClientNonce = request.ClientNonce ?? string.Empty,
|
||||
ProviderKeyId = error == null ? signer?.ProviderKeyId ?? string.Empty : string.Empty,
|
||||
ProviderSignature = error == null && signer != null ? signer.Sign(request) : string.Empty,
|
||||
Error = error
|
||||
});
|
||||
return Task.CompletedTask;
|
||||
|
|
@ -114,6 +123,7 @@ namespace GameCult.Mesh
|
|||
{
|
||||
if (string.IsNullOrWhiteSpace(request.MessageId)) return "session-message-id-required";
|
||||
if (string.IsNullOrWhiteSpace(request.SourceRuntimeId)) return "source-runtime-id-required";
|
||||
if (!TryNonce(request.ClientNonce)) return "client-nonce-invalid";
|
||||
if (!string.Equals(request.AuthorityRuntimeId, _authorityRuntimeId, StringComparison.Ordinal))
|
||||
return "target-runtime-mismatch";
|
||||
if (!_verseIds.Contains(request.VerseId ?? string.Empty)) return "verse-not-served";
|
||||
|
|
@ -122,6 +132,13 @@ namespace GameCult.Mesh
|
|||
return null;
|
||||
}
|
||||
|
||||
private static bool TryNonce(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
try { return Convert.FromBase64String(value).Length == 32; }
|
||||
catch (FormatException) { return false; }
|
||||
}
|
||||
|
||||
private static HashSet<string> Clean(IEnumerable<string> values, string name)
|
||||
{
|
||||
if (values == null) throw new ArgumentNullException(name);
|
||||
|
|
@ -145,26 +162,45 @@ namespace GameCult.Mesh
|
|||
string sourceRuntimeId,
|
||||
CultMeshSessionTarget target,
|
||||
CultMeshProtocolId protocol,
|
||||
string routeGeneration,
|
||||
CultMeshTransportCandidate candidate,
|
||||
CultMeshAuthorityTrustPolicy? trust,
|
||||
DateTimeOffset now,
|
||||
TimeSpan timeout,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
if (client == null) throw new ArgumentNullException(nameof(client));
|
||||
if (candidate == null) throw new ArgumentNullException(nameof(candidate));
|
||||
var route = candidate.AuthorityRoute ?? new CultMeshAuthorityRoute(
|
||||
target.AuthorityRuntimeId,
|
||||
candidate.Endpoint,
|
||||
new[] { protocol.Value },
|
||||
candidate.Priority,
|
||||
candidate.Generation);
|
||||
var verifiedRoute = candidate.VerifiedAuthority ??
|
||||
(trust ?? throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||
CultMeshSessionFailureReason.Authentication,
|
||||
"No consumer trust policy verified this CultMesh route.",
|
||||
candidate.Endpoint))).Verify(target.VerseId, route, now);
|
||||
if (client is ICultMeshVerifiedSchemaClient verified)
|
||||
{
|
||||
if (!verified.IsVerifiedFor(
|
||||
target.VerseId,
|
||||
target.AuthorityRuntimeId,
|
||||
protocol.Value,
|
||||
routeGeneration ?? string.Empty))
|
||||
candidate.Generation))
|
||||
{
|
||||
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||
CultMeshSessionFailureReason.Authority,
|
||||
$"Native transport identity did not match CultMesh target '{target}' for '{protocol}'."));
|
||||
}
|
||||
return;
|
||||
// Native identity is sufficient only for an explicitly selected
|
||||
// loopback development route. Remote schema sessions still prove
|
||||
// possession of the provider key certified by Odin.
|
||||
if (verifiedRoute.IsLocalDevelopment) return;
|
||||
}
|
||||
var messageId = Guid.NewGuid().ToString("N");
|
||||
var nonce = new byte[32];
|
||||
using (var random = RandomNumberGenerator.Create()) random.GetBytes(nonce);
|
||||
var completion = new TaskCompletionSource<CultMeshSessionAcceptedMessage>(
|
||||
TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
client.OnCultNet<CultMeshSessionAcceptedMessage>(response =>
|
||||
|
|
@ -172,15 +208,17 @@ namespace GameCult.Mesh
|
|||
if (string.Equals(response.MessageId, messageId, StringComparison.Ordinal))
|
||||
completion.TrySetResult(response);
|
||||
});
|
||||
client.SendCultNet(new CultMeshSessionOpenMessage
|
||||
var request = new CultMeshSessionOpenMessage
|
||||
{
|
||||
MessageId = messageId,
|
||||
SourceRuntimeId = sourceRuntimeId,
|
||||
VerseId = target.VerseId,
|
||||
AuthorityRuntimeId = target.AuthorityRuntimeId,
|
||||
ProtocolId = protocol.Value,
|
||||
RouteGeneration = routeGeneration ?? string.Empty
|
||||
});
|
||||
RouteGeneration = candidate.Generation,
|
||||
ClientNonce = Convert.ToBase64String(nonce)
|
||||
};
|
||||
client.SendCultNet(request);
|
||||
|
||||
using var deadline = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
|
||||
deadline.CancelAfter(timeout);
|
||||
|
|
@ -201,7 +239,8 @@ namespace GameCult.Mesh
|
|||
!string.Equals(accepted.VerseId, target.VerseId, StringComparison.Ordinal) ||
|
||||
!string.Equals(accepted.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) ||
|
||||
!string.Equals(accepted.ProtocolId, protocol.Value, StringComparison.Ordinal) ||
|
||||
!string.Equals(accepted.RouteGeneration, routeGeneration ?? string.Empty, StringComparison.Ordinal))
|
||||
!string.Equals(accepted.RouteGeneration, candidate.Generation, StringComparison.Ordinal) ||
|
||||
!CultMeshAuthorityProof.VerifySession(request, accepted, verifiedRoute))
|
||||
{
|
||||
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||
CultMeshSessionFailureReason.Authority,
|
||||
|
|
|
|||
|
|
@ -86,19 +86,28 @@ namespace GameCult.Mesh
|
|||
CultMeshTransportPathKind pathKind = CultMeshTransportPathKind.Direct,
|
||||
int priority = 0,
|
||||
string authorityRuntimeId = "",
|
||||
string generation = "")
|
||||
string generation = "",
|
||||
CultMeshAuthorityRoute? authorityRoute = null)
|
||||
{
|
||||
Endpoint = string.IsNullOrWhiteSpace(endpoint) ? throw new ArgumentException("Physical endpoint is required.", nameof(endpoint)) : endpoint;
|
||||
PathKind = pathKind;
|
||||
Priority = priority;
|
||||
AuthorityRuntimeId = authorityRuntimeId?.Trim() ?? string.Empty;
|
||||
Generation = generation?.Trim() ?? string.Empty;
|
||||
AuthorityRoute = authorityRoute;
|
||||
}
|
||||
public string Endpoint { get; }
|
||||
public CultMeshTransportPathKind PathKind { get; }
|
||||
public int Priority { get; }
|
||||
public string AuthorityRuntimeId { get; }
|
||||
public string Generation { get; }
|
||||
public CultMeshAuthorityRoute? AuthorityRoute { get; }
|
||||
internal CultMeshVerifiedAuthorityRoute? VerifiedAuthority { get; private set; }
|
||||
internal CultMeshTransportCandidate WithVerifiedAuthority(CultMeshVerifiedAuthorityRoute verified)
|
||||
{
|
||||
VerifiedAuthority = verified ?? throw new ArgumentNullException(nameof(verified));
|
||||
return this;
|
||||
}
|
||||
}
|
||||
|
||||
public enum CultMeshSessionStatus { Connecting, Online, Degraded, Reconnecting, Offline }
|
||||
|
|
@ -534,6 +543,9 @@ namespace GameCult.Mesh
|
|||
public int MaxRacedCandidates { get; set; } = 2;
|
||||
public string RuntimeId { get; set; } = "cultmesh-client";
|
||||
public TimeSpan IdentityHandshakeTimeout { get; set; } = TimeSpan.FromSeconds(5);
|
||||
/// <summary>Gets or sets the consumer-owned trust roots and explicit local-development policy.</summary>
|
||||
public CultMeshAuthorityTrustPolicy Trust { get; set; } = new(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote);
|
||||
}
|
||||
|
||||
public sealed class CultMeshSessionManager : IDisposable
|
||||
|
|
@ -1039,7 +1051,9 @@ namespace GameCult.Mesh
|
|||
_options.RuntimeId,
|
||||
target,
|
||||
protocol,
|
||||
route.Candidate.Generation,
|
||||
route.Candidate,
|
||||
_options.Trust,
|
||||
_options.Clock.UtcNow,
|
||||
_options.IdentityHandshakeTimeout).ConfigureAwait(false);
|
||||
return new ConnectedPath(route.Candidate, client);
|
||||
}
|
||||
|
|
@ -1065,7 +1079,7 @@ namespace GameCult.Mesh
|
|||
private CultMeshSessionException Failure(CultMeshSessionFailureReason reason, string message, Exception? inner = null) =>
|
||||
new CultMeshSessionException(new CultMeshSessionFailure(reason, message), inner);
|
||||
|
||||
private static CultMeshTransportCandidate[] BoundCandidates(
|
||||
private CultMeshTransportCandidate[] BoundCandidates(
|
||||
CultMeshDiscoveryState discovery,
|
||||
CultMeshSessionTarget target,
|
||||
CultMeshProtocolId protocol)
|
||||
|
|
@ -1080,10 +1094,15 @@ namespace GameCult.Mesh
|
|||
.GroupBy(route => route.Endpoint, StringComparer.Ordinal)
|
||||
.Select(group => group.OrderBy(route => route.Priority).First())
|
||||
.Select(route => new CultMeshTransportCandidate(
|
||||
route.Endpoint,
|
||||
priority: route.Priority,
|
||||
authorityRuntimeId: route.AuthorityRuntimeId,
|
||||
generation: route.Generation))
|
||||
route.Endpoint,
|
||||
priority: route.Priority,
|
||||
authorityRuntimeId: route.AuthorityRuntimeId,
|
||||
generation: route.Generation,
|
||||
authorityRoute: route)
|
||||
.WithVerifiedAuthority(_options.Trust.Verify(
|
||||
target.VerseId,
|
||||
route,
|
||||
_options.Clock.UtcNow)))
|
||||
.OrderBy(candidate => candidate.Priority)
|
||||
.ThenBy(candidate => candidate.Endpoint, StringComparer.Ordinal)
|
||||
.ToArray();
|
||||
|
|
|
|||
|
|
@ -17,6 +17,7 @@ namespace GameCult.Mesh
|
|||
public string VerseId { get; set; } = string.Empty;
|
||||
public string AuthorityRuntimeId { get; set; } = string.Empty;
|
||||
public string RouteGeneration { get; set; } = string.Empty;
|
||||
public CultMeshSessionProofSigner? ProofSigner { get; set; }
|
||||
|
||||
internal void Validate()
|
||||
{
|
||||
|
|
@ -344,7 +345,10 @@ namespace GameCult.Mesh
|
|||
var requestBytes = await CultMeshTcpContentWire.ReadFrameAsync(stream, cancellationToken).ConfigureAwait(false);
|
||||
var request = CultNetSchemaMessageSerialization.Deserialize(requestBytes) as CultMeshSessionOpenMessage
|
||||
?? throw new InvalidDataException("TCP content session did not begin with an authority handshake.");
|
||||
var accepted = string.Equals(request.VerseId, _options.VerseId, StringComparison.Ordinal) &&
|
||||
var accepted = !string.IsNullOrWhiteSpace(request.MessageId) &&
|
||||
!string.IsNullOrWhiteSpace(request.SourceRuntimeId) &&
|
||||
IsNonce(request.ClientNonce) &&
|
||||
string.Equals(request.VerseId, _options.VerseId, StringComparison.Ordinal) &&
|
||||
string.Equals(request.AuthorityRuntimeId, _options.AuthorityRuntimeId, StringComparison.Ordinal) &&
|
||||
string.Equals(request.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) &&
|
||||
string.Equals(request.RouteGeneration, _options.RouteGeneration, StringComparison.Ordinal);
|
||||
|
|
@ -356,6 +360,11 @@ namespace GameCult.Mesh
|
|||
AuthorityRuntimeId = _options.AuthorityRuntimeId,
|
||||
ProtocolId = CultMeshProtocols.Content.Value,
|
||||
RouteGeneration = _options.RouteGeneration,
|
||||
ClientNonce = request.ClientNonce ?? string.Empty,
|
||||
ProviderKeyId = accepted ? _options.ProofSigner?.ProviderKeyId ?? string.Empty : string.Empty,
|
||||
ProviderSignature = accepted && _options.ProofSigner != null
|
||||
? _options.ProofSigner.Sign(request)
|
||||
: string.Empty,
|
||||
Error = accepted ? null : "Content session target does not match this authority route."
|
||||
};
|
||||
await CultMeshTcpContentWire.WriteFrameAsync(
|
||||
|
|
@ -365,6 +374,13 @@ namespace GameCult.Mesh
|
|||
return accepted;
|
||||
}
|
||||
|
||||
private static bool IsNonce(string? value)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(value)) return false;
|
||||
try { return Convert.FromBase64String(value).Length == 32; }
|
||||
catch (FormatException) { return false; }
|
||||
}
|
||||
|
||||
private async Task SendChunkAsync(
|
||||
Stream stream,
|
||||
CultMeshContentChunkRequestMessage request,
|
||||
|
|
@ -473,7 +489,8 @@ namespace GameCult.Mesh
|
|||
VerseId = target.VerseId,
|
||||
AuthorityRuntimeId = target.AuthorityRuntimeId,
|
||||
ProtocolId = CultMeshProtocols.Content.Value,
|
||||
RouteGeneration = candidate.Generation
|
||||
RouteGeneration = candidate.Generation,
|
||||
ClientNonce = CreateNonce()
|
||||
};
|
||||
await WriteFrameAsync(stream, CultNetSchemaMessageSerialization.Serialize(request), cancellationToken)
|
||||
.ConfigureAwait(false);
|
||||
|
|
@ -485,13 +502,22 @@ namespace GameCult.Mesh
|
|||
!string.Equals(response.VerseId, target.VerseId, StringComparison.Ordinal) ||
|
||||
!string.Equals(response.AuthorityRuntimeId, target.AuthorityRuntimeId, StringComparison.Ordinal) ||
|
||||
!string.Equals(response.ProtocolId, CultMeshProtocols.Content.Value, StringComparison.Ordinal) ||
|
||||
!string.Equals(response.RouteGeneration, candidate.Generation, StringComparison.Ordinal))
|
||||
!string.Equals(response.RouteGeneration, candidate.Generation, StringComparison.Ordinal) ||
|
||||
candidate.VerifiedAuthority == null ||
|
||||
!CultMeshAuthorityProof.VerifySession(request, response, candidate.VerifiedAuthority))
|
||||
throw new CultMeshSessionException(new CultMeshSessionFailure(
|
||||
CultMeshSessionFailureReason.Authority,
|
||||
response.Error ?? "TCP content endpoint did not prove the selected authority route.",
|
||||
candidate.Endpoint));
|
||||
}
|
||||
|
||||
private static string CreateNonce()
|
||||
{
|
||||
var nonce = new byte[32];
|
||||
using (var random = System.Security.Cryptography.RandomNumberGenerator.Create()) random.GetBytes(nonce);
|
||||
return Convert.ToBase64String(nonce);
|
||||
}
|
||||
|
||||
public static async Task<byte[]> ReadFrameAsync(Stream stream, CancellationToken cancellationToken)
|
||||
{
|
||||
var header = new byte[HeaderBytes];
|
||||
|
|
|
|||
|
|
@ -102,7 +102,8 @@ namespace GameCult.Mesh
|
|||
string endpoint,
|
||||
IEnumerable<string>? protocolIds = null,
|
||||
int priority = 0,
|
||||
string? generation = null)
|
||||
string? generation = null,
|
||||
CultMeshRouteCertificate? certificate = null)
|
||||
{
|
||||
AuthorityRuntimeId = RequireNonEmpty(authorityRuntimeId, nameof(authorityRuntimeId));
|
||||
Endpoint = RequireNonEmpty(endpoint, nameof(endpoint));
|
||||
|
|
@ -112,6 +113,7 @@ namespace GameCult.Mesh
|
|||
Generation = string.IsNullOrWhiteSpace(generation)
|
||||
? AuthorityRuntimeId + "@" + Endpoint
|
||||
: generation!.Trim();
|
||||
Certificate = certificate;
|
||||
}
|
||||
|
||||
public string AuthorityRuntimeId { get; }
|
||||
|
|
@ -119,6 +121,8 @@ namespace GameCult.Mesh
|
|||
public IReadOnlyList<string> ProtocolIds { get; }
|
||||
public int Priority { get; }
|
||||
public string Generation { get; }
|
||||
/// <summary>Gets the optional Odin-signed binding from this route to its provider key.</summary>
|
||||
public CultMeshRouteCertificate? Certificate { get; }
|
||||
|
||||
public bool Supports(CultMeshProtocolId protocol) =>
|
||||
protocol != null &&
|
||||
|
|
|
|||
|
|
@ -39,7 +39,17 @@ namespace GameCult.Mesh
|
|||
Endpoint = route.Endpoint,
|
||||
ProtocolIds = route.ProtocolIds.ToArray(),
|
||||
Priority = route.Priority,
|
||||
Generation = route.Generation
|
||||
Generation = route.Generation,
|
||||
Certificate = route.Certificate == null ? null : new CultMeshRouteCertificateMessage
|
||||
{
|
||||
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
|
||||
}
|
||||
}).ToArray(),
|
||||
ParentVerseId = verse.ParentVerseId,
|
||||
Description = verse.Description
|
||||
|
|
@ -63,7 +73,16 @@ namespace GameCult.Mesh
|
|||
route.Endpoint,
|
||||
route.ProtocolIds,
|
||||
route.Priority,
|
||||
route.Generation)).ToArray();
|
||||
route.Generation,
|
||||
route.Certificate == null ? null : new CultMeshRouteCertificate(
|
||||
new CultMeshEcdsaP256PublicKey(
|
||||
route.Certificate.ProviderKeyId,
|
||||
route.Certificate.ProviderPublicKeyX,
|
||||
route.Certificate.ProviderPublicKeyY),
|
||||
route.Certificate.OdinKeyId,
|
||||
route.Certificate.IssuedAtUnixMilliseconds,
|
||||
route.Certificate.ExpiresAtUnixMilliseconds,
|
||||
route.Certificate.Signature))).ToArray();
|
||||
return new CultMeshVerseDescriptor(
|
||||
message.VerseId,
|
||||
message.DisplayName,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,8 @@ This is the implementation map for the browser Verse boundary.
|
|||
`samples/eve-two-runtime` remains the clean package-consumer/jsdom checkpoint.
|
||||
`samples/eve-browser-network` runs a durable C# provider, a real Chromium Eve
|
||||
lowerer, an independent C# headless observer, and a separate local Odin fixture
|
||||
over authenticated binary CultNet WebSocket routes. It proves one canonical
|
||||
over binary loopback CultNet WebSocket routes under an explicit
|
||||
`local-development` trust policy. It proves one canonical
|
||||
command effect, idempotent retry, shared observation, provider restart on a new
|
||||
physical route, browser rediscovery, retained lease resubscription, and a second
|
||||
canonical command after reconnection.
|
||||
|
|
@ -29,6 +30,10 @@ not a complete public Verse proof:
|
|||
`cultmesh.verse_catalog_request.v0` and
|
||||
`cultmesh.verse_catalog_response.v0`; application callbacks no longer need to
|
||||
smuggle provider URLs into the browser client;
|
||||
- `CultMeshBrowserClient` owns the final authority gate: remote use requires a
|
||||
consumer-pinned Odin P-256 root, an Odin-signed route certificate, `wss://`,
|
||||
and provider possession proof over a fresh nonce. A catalog source cannot
|
||||
bypass that gate by returning a route-shaped object;
|
||||
- Kotlin has a channel-aware WebSocket transport adapter, TypeScript has the
|
||||
browser client, and `GameCult.Networking.WebSockets` provides the bounded C#
|
||||
schema server host adapter;
|
||||
|
|
@ -58,9 +63,9 @@ Owner:
|
|||
|
||||
Inputs:
|
||||
|
||||
- an Odin-discovered WebSocket transport candidate;
|
||||
- an Odin-discovered route certificate and consumer-configured Odin root;
|
||||
- stable Verse and provider identities;
|
||||
- authenticated session evidence;
|
||||
- provider key possession over a fresh client nonce;
|
||||
- requested schema ids and record keys;
|
||||
- typed operation invocations and idempotency keys;
|
||||
- canonical CultNet schema-v0 MessagePack messages.
|
||||
|
|
|
|||
|
|
@ -9,9 +9,10 @@ selection owner then resolves the provider runtime from Odin's descriptor before
|
|||
opening the session. Discovery owns the current physical routes, and the session
|
||||
manager owns connection reuse, path rotation, and transport failure state.
|
||||
Each Odin route binds that authority runtime to one endpoint, protocol set, and
|
||||
generation. The client verifies the same tuple with the connected peer before
|
||||
the session becomes online; runtime membership and endpoint reachability are
|
||||
not independent routing evidence.
|
||||
generation. For a remote Verse, Odin signs that exact tuple and the provider's
|
||||
P-256 public key. The client pins an Odin root from its own configuration, then
|
||||
requires the connected provider to sign a fresh client nonce. A route cannot
|
||||
become usable by merely repeating the expected Verse and runtime strings.
|
||||
|
||||
```csharp
|
||||
using GameCult.Mesh;
|
||||
|
|
@ -22,9 +23,22 @@ const string verseId = "sample.counter";
|
|||
var target = new CultMeshSessionTarget(verseId, "sample.counter-daemon");
|
||||
using var timeout = new CancellationTokenSource(TimeSpan.FromSeconds(30));
|
||||
var cancellationToken = timeout.Token;
|
||||
var odinRoot = new CultMeshEcdsaP256PublicKey(
|
||||
Environment.GetEnvironmentVariable("ODIN_ROOT_KEY_ID")
|
||||
?? throw new InvalidOperationException("Set ODIN_ROOT_KEY_ID."),
|
||||
Environment.GetEnvironmentVariable("ODIN_ROOT_P256_X")
|
||||
?? throw new InvalidOperationException("Set ODIN_ROOT_P256_X."),
|
||||
Environment.GetEnvironmentVariable("ODIN_ROOT_P256_Y")
|
||||
?? throw new InvalidOperationException("Set ODIN_ROOT_P256_Y."));
|
||||
using var mesh = new CultMeshClient(new CultMeshClientOptions
|
||||
{
|
||||
RendezvousEndpoints = new[] { odinEndpoint }
|
||||
RendezvousEndpoints = new[] { odinEndpoint },
|
||||
Sessions = new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
||||
new[] { odinRoot })
|
||||
}
|
||||
});
|
||||
|
||||
using var counterLease = await mesh.LeaseDocumentAsync<CounterState>(
|
||||
|
|
@ -64,6 +78,21 @@ using var stateWatch = session.WatchState().Subscribe(state =>
|
|||
Application code does not reconnect, sleep, rank endpoints, or replace the
|
||||
session. Those decisions belong to CultMesh.
|
||||
|
||||
For a deliberately local, moddable Verse, select the exception explicitly and
|
||||
use only in-process or loopback routes:
|
||||
|
||||
```csharp
|
||||
Sessions = new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
|
||||
}
|
||||
```
|
||||
|
||||
`LocalDevelopment` never accepts an unsigned non-loopback route. Verse names,
|
||||
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.
|
||||
|
||||
Fast session-management check (this uses controlled transports; it is not the
|
||||
network proof):
|
||||
|
||||
|
|
@ -71,7 +100,7 @@ network proof):
|
|||
dotnet test tests/GameCult.Mesh.Tests/GameCult.Mesh.Tests.csproj --filter FullyQualifiedName~CultMeshSessionManagerTests
|
||||
```
|
||||
|
||||
The real checkpoint in chapter 4 additionally advertises a better-priority
|
||||
The local checkpoint in chapter 4 additionally advertises a better-priority
|
||||
endpoint for the wrong authority runtime. Both the C# and browser clients must
|
||||
ignore it, verify the intended peer, survive route replacement, and reject
|
||||
wrong-source operation responses.
|
||||
|
|
|
|||
|
|
@ -64,13 +64,20 @@ operation.
|
|||
transport diagnostics. None of these grant provider authority or become content
|
||||
identity.
|
||||
|
||||
Before a schema, content, or realtime session becomes online, the transport
|
||||
must prove that the connected peer matches the selected Verse, authority
|
||||
runtime, protocol, and route generation. TCP schema and content lanes use the
|
||||
portable CultNet session-open handshake. QUIC uses the exact Odin-bound route
|
||||
plus its TLS endpoint/certificate proof. A connector that returns bytes without
|
||||
authority proof is rejected; racing every endpoint in a Verse descriptor is
|
||||
not route selection.
|
||||
Before a schema, content, or realtime session becomes online, consumer-owned
|
||||
trust verifies an Odin-signed route certificate binding Verse, authority
|
||||
runtime, protocol set, endpoint, generation, validity interval, and provider
|
||||
P-256 key. Schema and content lanes then require a fresh client nonce signed by
|
||||
that provider key. QUIC uses the signed endpoint plus its TLS
|
||||
endpoint/certificate proof. A connector that returns bytes without this chain
|
||||
is rejected; racing every endpoint in a Verse descriptor is not route
|
||||
selection. Unsigned traffic exists only under an explicitly selected
|
||||
`LocalDevelopment` policy and only for in-process or loopback routes.
|
||||
|
||||
Route and session signatures use ECDSA P-256 with SHA-256 and fixed-width IEEE
|
||||
P1363 `r || s` encoding, matching browser WebCrypto. Their canonical
|
||||
transcripts are ordered UTF-8 fields prefixed by four-byte big-endian lengths;
|
||||
serializer bytes are never signature authority.
|
||||
|
||||
**Forbidden writers:** applications and renderers do not choose unadvertised
|
||||
endpoints, implement reconnect loops, or bypass verification. Schema messages
|
||||
|
|
@ -94,7 +101,8 @@ new seam does not pretend that migration has already occurred.
|
|||
## Security and release gate
|
||||
|
||||
The current TCP control and content connectors are plaintext and therefore
|
||||
restricted to local or otherwise trusted deployment. Adding TLS must not move
|
||||
restricted to explicit loopback development. They cannot carry an authenticated
|
||||
remote session. Adding TLS must not move
|
||||
discovery, authorization, content verification, or application semantics into
|
||||
the TCP adapter.
|
||||
|
||||
|
|
|
|||
|
|
@ -136,9 +136,9 @@ namespace GameCult.Networking
|
|||
/// </summary>
|
||||
public const string VerseCatalogResponse = "cultmesh.verse_catalog_response.v0";
|
||||
/// <summary>CultMesh session identity request contract identifier.</summary>
|
||||
public const string SessionOpen = "cultmesh.session_open.v1";
|
||||
public const string SessionOpen = "cultmesh.session_open.v2";
|
||||
/// <summary>CultMesh verified session identity response contract identifier.</summary>
|
||||
public const string SessionAccepted = "cultmesh.session_accepted.v1";
|
||||
public const string SessionAccepted = "cultmesh.session_accepted.v2";
|
||||
/// <summary>
|
||||
/// CultMesh peer exchange request contract identifier.
|
||||
/// </summary>
|
||||
|
|
@ -1194,6 +1194,7 @@ namespace GameCult.Networking
|
|||
[Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty;
|
||||
[Key("protocolId")] public string ProtocolId { get; set; } = string.Empty;
|
||||
[Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty;
|
||||
[Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>Accepts or rejects a CultMesh session with the server's actual identity.</summary>
|
||||
|
|
@ -1207,6 +1208,9 @@ namespace GameCult.Networking
|
|||
[Key("authorityRuntimeId")] public string AuthorityRuntimeId { get; set; } = string.Empty;
|
||||
[Key("protocolId")] public string ProtocolId { get; set; } = string.Empty;
|
||||
[Key("routeGeneration")] public string RouteGeneration { get; set; } = string.Empty;
|
||||
[Key("clientNonce")] public string ClientNonce { get; set; } = string.Empty;
|
||||
[Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty;
|
||||
[Key("providerSignature")] public string ProviderSignature { get; set; } = string.Empty;
|
||||
[Key("error")] public string? Error { get; set; }
|
||||
}
|
||||
|
||||
|
|
@ -1308,6 +1312,19 @@ namespace GameCult.Networking
|
|||
[Key("protocolIds")] public string[] ProtocolIds { get; set; } = Array.Empty<string>();
|
||||
[Key("priority")] public int Priority { get; set; }
|
||||
[Key("generation")] public string Generation { get; set; } = string.Empty;
|
||||
[Key("certificate")] public CultMeshRouteCertificateMessage? Certificate { get; set; }
|
||||
}
|
||||
|
||||
[MessagePackObject]
|
||||
public class CultMeshRouteCertificateMessage
|
||||
{
|
||||
[Key("providerKeyId")] public string ProviderKeyId { get; set; } = string.Empty;
|
||||
[Key("providerPublicKeyX")] public string ProviderPublicKeyX { get; set; } = string.Empty;
|
||||
[Key("providerPublicKeyY")] public string ProviderPublicKeyY { get; set; } = string.Empty;
|
||||
[Key("odinKeyId")] public string OdinKeyId { get; set; } = string.Empty;
|
||||
[Key("issuedAtUnixMilliseconds")] public long IssuedAtUnixMilliseconds { get; set; }
|
||||
[Key("expiresAtUnixMilliseconds")] public long ExpiresAtUnixMilliseconds { get; set; }
|
||||
[Key("signature")] public string Signature { get; set; } = string.Empty;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
|
|
|
|||
241
tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs
Normal file
241
tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Security.Cryptography;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using FluentAssertions;
|
||||
using GameCult.Networking;
|
||||
using NUnit.Framework;
|
||||
|
||||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
public sealed class CultMeshAuthorityProofTests
|
||||
{
|
||||
private static readonly DateTimeOffset Now = DateTimeOffset.FromUnixTimeMilliseconds(1_800_000_000_000);
|
||||
|
||||
[Test]
|
||||
public void SignedRouteBindsEveryAuthorityFieldToConsumerTrust()
|
||||
{
|
||||
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var route = SignedRoute(odin, provider);
|
||||
var trust = Trust(odin);
|
||||
|
||||
trust.Validate("aetheria", route, Now);
|
||||
var wireRoundTrip = new CultMeshVerseDescriptor(
|
||||
"aetheria",
|
||||
"Aetheria",
|
||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
authorityRoutes: new[] { route })
|
||||
.ToMessage()
|
||||
.ToVerseDescriptor()
|
||||
.AuthorityRoutes[0];
|
||||
trust.Validate("aetheria", wireRoundTrip, Now);
|
||||
wireRoundTrip.Certificate!.ProviderKey.KeyId.Should().Be("provider-1");
|
||||
|
||||
Action mutatedEndpoint = () => trust.Validate("aetheria", new CultMeshAuthorityRoute(
|
||||
route.AuthorityRuntimeId,
|
||||
"wss://evil.example/mesh",
|
||||
route.ProtocolIds,
|
||||
route.Priority,
|
||||
route.Generation,
|
||||
route.Certificate), Now);
|
||||
mutatedEndpoint.Should().Throw<CultMeshSessionException>()
|
||||
.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authentication);
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void RemoteTrustRejectsUnknownExpiredAndUnsignedRoutes()
|
||||
{
|
||||
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var stranger = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var route = SignedRoute(odin, provider);
|
||||
|
||||
Action unknownRoot = () => Trust(stranger).Validate("aetheria", route, Now);
|
||||
Action expired = () => Trust(odin).Validate("aetheria", route, Now.AddHours(2));
|
||||
Action unsigned = () => Trust(odin).Validate("aetheria", new CultMeshAuthorityRoute(
|
||||
"aetheria-daemon", "wss://provider.example/mesh",
|
||||
new[] { CultMeshProtocols.Documents.Value }, generation: "generation-1"), Now);
|
||||
|
||||
unknownRoot.Should().Throw<CultMeshSessionException>();
|
||||
expired.Should().Throw<CultMeshSessionException>();
|
||||
unsigned.Should().Throw<CultMeshSessionException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void UnsignedRouteRequiresExplicitLoopbackDevelopmentPolicy()
|
||||
{
|
||||
var local = new CultMeshAuthorityRoute(
|
||||
"aetheria-daemon", "rudp://127.0.0.1:3076",
|
||||
new[] { CultMeshProtocols.Documents.Value }, generation: "local");
|
||||
var remote = new CultMeshAuthorityRoute(
|
||||
"aetheria-daemon", "rudp://192.0.2.10:3076",
|
||||
new[] { CultMeshProtocols.Documents.Value }, generation: "remote");
|
||||
var policy = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment);
|
||||
|
||||
policy.Validate("aetheria", local, Now);
|
||||
Action remoteAttempt = () => policy.Validate("aetheria", remote, Now);
|
||||
remoteAttempt.Should().Throw<CultMeshSessionException>();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void ProviderProofRejectsCredentialFreeEchoAndNonceReplay()
|
||||
{
|
||||
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var route = SignedRoute(odin, provider);
|
||||
var trust = Trust(odin);
|
||||
var signer = new CultMeshSessionProofSigner(route, provider);
|
||||
var request = Request("nonce-a");
|
||||
var signed = Accepted(request, signer.Sign(request));
|
||||
|
||||
CultMeshAuthorityProof.VerifySessionProof(request, signed, "aetheria", route, trust, Now).Should().BeTrue();
|
||||
|
||||
var echo = Accepted(request, string.Empty);
|
||||
CultMeshAuthorityProof.VerifySessionProof(request, echo, "aetheria", route, trust, Now).Should().BeFalse();
|
||||
|
||||
var replayedRequest = Request("nonce-b");
|
||||
CultMeshAuthorityProof.VerifySessionProof(replayedRequest, signed, "aetheria", route, trust, Now).Should().BeFalse();
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task SessionManagerRejectsPeerThatOnlyEchoesCertifiedIdentityStrings()
|
||||
{
|
||||
using var odin = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
using var provider = ECDsa.Create(ECCurve.NamedCurves.nistP256);
|
||||
var now = DateTimeOffset.UtcNow;
|
||||
var route = CultMeshAuthorityProof.CreateSignedRoute(
|
||||
"aetheria", "aetheria-daemon", "wss://provider.example/mesh",
|
||||
new[] { CultMeshProtocols.Documents.Value }, 0, "generation-live",
|
||||
CultMeshEcdsaP256PublicKey.From("provider-live", provider), "odin-live",
|
||||
now.AddMinutes(-1), now.AddMinutes(5), odin);
|
||||
var descriptor = new CultMeshVerseDescriptor(
|
||||
"aetheria", "Aetheria", CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
authorityRoutes: new[] { route });
|
||||
using var discovery = new CultMeshDiscoveryService(new[] { new SignedRouteSource(descriptor) });
|
||||
using var manager = new CultMeshSessionManager(
|
||||
discovery,
|
||||
new[] { new EchoConnector() },
|
||||
new CultMeshSessionManagerOptions
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
||||
new[] { CultMeshEcdsaP256PublicKey.From("odin-live", odin) })
|
||||
});
|
||||
|
||||
Func<Task> connect = async () => await manager.ConnectAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria-daemon"),
|
||||
CultMeshProtocols.Documents);
|
||||
|
||||
(await connect.Should().ThrowAsync<CultMeshSessionException>())
|
||||
.Which.Failure.Reason.Should().Be(CultMeshSessionFailureReason.Authority);
|
||||
}
|
||||
|
||||
private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) =>
|
||||
CultMeshAuthorityProof.CreateSignedRoute(
|
||||
"aetheria",
|
||||
"aetheria-daemon",
|
||||
"wss://provider.example/mesh",
|
||||
new[] { CultMeshProtocols.Documents.Value },
|
||||
0,
|
||||
"generation-1",
|
||||
CultMeshEcdsaP256PublicKey.From("provider-1", provider),
|
||||
"odin-root-1",
|
||||
Now.AddMinutes(-1),
|
||||
Now.AddHours(1),
|
||||
odin);
|
||||
|
||||
private static CultMeshAuthorityTrustPolicy Trust(ECDsa odin) => new(
|
||||
CultMeshAuthorityTrustMode.AuthenticatedRemote,
|
||||
new[] { CultMeshEcdsaP256PublicKey.From("odin-root-1", odin) });
|
||||
|
||||
private static GameCult.Networking.CultMeshSessionOpenMessage Request(string nonceLabel)
|
||||
{
|
||||
using var hash = SHA256.Create();
|
||||
return new GameCult.Networking.CultMeshSessionOpenMessage
|
||||
{
|
||||
MessageId = "message-1",
|
||||
SourceRuntimeId = "browser-1",
|
||||
VerseId = "aetheria",
|
||||
AuthorityRuntimeId = "aetheria-daemon",
|
||||
ProtocolId = CultMeshProtocols.Documents.Value,
|
||||
RouteGeneration = "generation-1",
|
||||
ClientNonce = Convert.ToBase64String(hash.ComputeHash(System.Text.Encoding.UTF8.GetBytes(nonceLabel)))
|
||||
};
|
||||
}
|
||||
|
||||
private static GameCult.Networking.CultMeshSessionAcceptedMessage Accepted(
|
||||
GameCult.Networking.CultMeshSessionOpenMessage request,
|
||||
string signature) => new()
|
||||
{
|
||||
MessageId = request.MessageId,
|
||||
Accepted = true,
|
||||
VerseId = request.VerseId,
|
||||
AuthorityRuntimeId = request.AuthorityRuntimeId,
|
||||
ProtocolId = request.ProtocolId,
|
||||
RouteGeneration = request.RouteGeneration,
|
||||
ClientNonce = request.ClientNonce,
|
||||
ProviderKeyId = "provider-1",
|
||||
ProviderSignature = signature
|
||||
};
|
||||
|
||||
private sealed class SignedRouteSource : ICultMeshLookupSource
|
||||
{
|
||||
private readonly CultMeshVerseDescriptor _descriptor;
|
||||
public SignedRouteSource(CultMeshVerseDescriptor descriptor) => _descriptor = descriptor;
|
||||
public string SourceId => "signed-odin";
|
||||
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(
|
||||
CultMeshDiscoveryQuery query,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(new[]
|
||||
{
|
||||
new CultMeshDiscoveryObservation(
|
||||
_descriptor, SourceId, DateTimeOffset.UtcNow,
|
||||
DateTimeOffset.UtcNow.AddMinutes(1), CultMeshDiscoveryTrust.Signed)
|
||||
});
|
||||
}
|
||||
|
||||
private sealed class EchoConnector : ICultMeshTransportConnector
|
||||
{
|
||||
public string ConnectorId => "credential-free-echo";
|
||||
public int Priority => 0;
|
||||
public bool CanConnect(CultMeshTransportCandidate candidate) => true;
|
||||
public Task<ICultNetSchemaClient> ConnectAsync(
|
||||
CultMeshTransportCandidate candidate,
|
||||
CultMeshProtocolId protocol,
|
||||
CancellationToken cancellationToken = default) =>
|
||||
Task.FromResult<ICultNetSchemaClient>(new EchoClient());
|
||||
}
|
||||
|
||||
private sealed class EchoClient : ICultNetSchemaClient
|
||||
{
|
||||
private Action<CultMeshSessionAcceptedMessage>? _accepted;
|
||||
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 = "provider-live",
|
||||
ProviderSignature = string.Empty
|
||||
});
|
||||
}
|
||||
public void OnCultNet<T>(Action<T> callback) where T : ICultNetSchemaMessage
|
||||
{
|
||||
if (typeof(T) == typeof(CultMeshSessionAcceptedMessage))
|
||||
_accepted = response => callback((T)(object)response);
|
||||
}
|
||||
public void Dispose() { }
|
||||
}
|
||||
}
|
||||
|
|
@ -26,7 +26,7 @@ public sealed class CultMeshBodySessionTests
|
|||
var client = new LoopbackClient(server);
|
||||
var connector = new LoopbackConnector(client);
|
||||
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource() });
|
||||
using var sessions = new CultMeshSessionManager(discovery, new[] { connector });
|
||||
using var sessions = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
var provider = new CultMeshSessionBodyProvider(
|
||||
"aetheria.daemon", sessions, new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
|
||||
|
|
@ -73,7 +73,7 @@ public sealed class CultMeshBodySessionTests
|
|||
});
|
||||
using var discovery = new CultMeshDiscoveryService(new[] { new RouteSource(endpoint) });
|
||||
using var sessions = new CultMeshSessionManager(discovery,
|
||||
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() });
|
||||
new ICultMeshTransportConnector[] { new CultMeshSchemaTransportConnector() }, CultMeshTestTrust.LocalSessions);
|
||||
var provider = new CultMeshSessionBodyProvider("aetheria.daemon", sessions,
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"),
|
||||
new CultMeshSessionBodyProviderOptions { ResponseTimeout = TimeSpan.FromSeconds(10) });
|
||||
|
|
@ -142,7 +142,7 @@ public sealed class CultMeshBodySessionTests
|
|||
private sealed class RouteSource : ICultMeshLookupSource
|
||||
{
|
||||
private readonly string _endpoint;
|
||||
public RouteSource(string endpoint = "rudp://body.test:3076") => _endpoint = endpoint;
|
||||
public RouteSource(string endpoint = "rudp://127.0.0.13:3076") => _endpoint = endpoint;
|
||||
public string SourceId => "test";
|
||||
public Task<IReadOnlyList<CultMeshDiscoveryObservation>> LookupAsync(CultMeshDiscoveryQuery query,
|
||||
CancellationToken cancellationToken = default)
|
||||
|
|
|
|||
|
|
@ -40,6 +40,7 @@ public sealed class CultMeshClientTests
|
|||
return new RendezvousClient();
|
||||
}
|
||||
},
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -48,7 +49,7 @@ public sealed class CultMeshClientTests
|
|||
|
||||
first.Should().BeSameAs(second);
|
||||
first.Target.Should().Be(Target);
|
||||
first.State.Path!.Endpoint.Should().Be("rudp://aetheria:3081");
|
||||
first.State.Path!.Endpoint.Should().Be("rudp://127.0.0.1:3081");
|
||||
discoveryClients.Should().Be(1);
|
||||
connector.ConnectCount.Should().Be(1);
|
||||
}
|
||||
|
|
@ -61,6 +62,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -82,6 +84,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -103,6 +106,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
using var lease = await mesh.LeaseDocumentAsync<ClientTestDocument>(Target, "surface:pilot");
|
||||
|
|
@ -125,6 +129,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -148,6 +153,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector },
|
||||
SubscriptionResponseTimeout = TimeSpan.FromMilliseconds(20)
|
||||
});
|
||||
|
|
@ -168,6 +174,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -187,6 +194,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -211,6 +219,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -237,6 +246,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -262,6 +272,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
var request = new ClientTestDocument { Id = "command:1", Text = "move" };
|
||||
|
|
@ -288,6 +299,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -319,6 +331,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector },
|
||||
OperationResponseTimeout = TimeSpan.FromSeconds(2)
|
||||
});
|
||||
|
|
@ -348,6 +361,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector },
|
||||
OperationResponseTimeout = TimeSpan.FromSeconds(5)
|
||||
});
|
||||
|
|
@ -378,6 +392,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
|
||||
|
|
@ -403,6 +418,7 @@ public sealed class CultMeshClientTests
|
|||
{
|
||||
RendezvousEndpoints = new[] { "rudp://odin:3076" },
|
||||
Discovery = new CultMeshVerseDiscoveryClientOptions { CreateClient = () => new RendezvousClient() },
|
||||
Sessions = CultMeshTestTrust.LocalSessions,
|
||||
Connectors = new[] { connector }
|
||||
});
|
||||
var opening = mesh.LeaseDocumentAsync<ClientTestDocument>(Target, "surface:pilot");
|
||||
|
|
@ -432,7 +448,7 @@ public sealed class CultMeshClientTests
|
|||
"Aetheria",
|
||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
new[] { "rudp://aetheria:3081" },
|
||||
new[] { "rudp://127.0.0.1:3081" },
|
||||
new[] { "aetheria-daemon" }).ToMessage()
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -55,7 +55,7 @@ public sealed class CultMeshContentSessionTests
|
|||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
new ICultMeshContentTransportConnector[] { connector });
|
||||
new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
var provider = new CultMeshSessionContentProvider(
|
||||
"aetheria.daemon",
|
||||
sessions,
|
||||
|
|
@ -99,7 +99,7 @@ public sealed class CultMeshContentSessionTests
|
|||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
new ICultMeshContentTransportConnector[] { new CultMeshTcpContentTransportConnector() });
|
||||
new ICultMeshContentTransportConnector[] { new CultMeshTcpContentTransportConnector() }, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
Func<Task> connect = async () => await sessions.ConnectContentAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
|
|
@ -151,7 +151,7 @@ public sealed class CultMeshContentSessionTests
|
|||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
new ICultMeshContentTransportConnector[] { connector });
|
||||
new ICultMeshContentTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
var provider = new CultMeshSessionContentProvider(
|
||||
"aetheria.daemon",
|
||||
sessions,
|
||||
|
|
@ -220,7 +220,7 @@ public sealed class CultMeshContentSessionTests
|
|||
{
|
||||
private readonly string _endpoint;
|
||||
|
||||
public RouteSource(string endpoint = "rudp://content.test:3076") => _endpoint = endpoint;
|
||||
public RouteSource(string endpoint = "rudp://127.0.0.14:3076") => _endpoint = endpoint;
|
||||
|
||||
public string SourceId => "test-rendezvous";
|
||||
|
||||
|
|
|
|||
|
|
@ -28,22 +28,22 @@ public sealed class CultMeshSessionManagerTests
|
|||
connected = candidate;
|
||||
return Task.FromResult<ICultNetSchemaClient>(new FakeSchemaClient());
|
||||
});
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
|
||||
await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
|
||||
|
||||
connected.Should().NotBeNull();
|
||||
connected!.Endpoint.Should().Be("rudp://aetheria:3076");
|
||||
connected!.Endpoint.Should().Be("rudp://127.0.0.1:3076");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public async Task Connect_ReusesInflightAndEstablishedSession()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var connector = new FakeConnector(async (_, _) => { await release.Task; return new FakeSchemaClient(); });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
var target = Target;
|
||||
|
||||
var first = manager.ConnectAsync(target, CultMeshProtocols.Documents);
|
||||
|
|
@ -61,19 +61,19 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task Connect_RacesPathsAndDisposesSuccessfulLoser()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://slow:3076", "rudp://fast:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.3:3076", "rudp://127.0.0.4:3076" });
|
||||
var slowRelease = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var slowClient = new FakeSchemaClient();
|
||||
var fastClient = new FakeSchemaClient();
|
||||
var connector = new FakeConnector(async (candidate, _) =>
|
||||
{
|
||||
if (candidate.Endpoint.Contains("slow")) { await slowRelease.Task; return slowClient; }
|
||||
if (candidate.Endpoint.Contains("127.0.0.3")) { await slowRelease.Task; return slowClient; }
|
||||
return fastClient;
|
||||
});
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
|
||||
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
|
||||
session.State.Path!.Endpoint.Should().Be("rudp://fast:3076");
|
||||
session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.4:3076");
|
||||
slowRelease.TrySetResult();
|
||||
await WaitUntilAsync(() => slowClient.DisposeCount == 1);
|
||||
fastClient.DisposeCount.Should().Be(0);
|
||||
|
|
@ -90,8 +90,8 @@ public sealed class CultMeshSessionManagerTests
|
|||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
authorityRoutes: new[]
|
||||
{
|
||||
new CultMeshAuthorityRoute("decoy-daemon", "rudp://decoy:3076"),
|
||||
new CultMeshAuthorityRoute("aetheria-daemon", "rudp://authority:3076")
|
||||
new CultMeshAuthorityRoute("decoy-daemon", "rudp://127.0.0.5:3076"),
|
||||
new CultMeshAuthorityRoute("aetheria-daemon", "rudp://127.0.0.6:3076")
|
||||
});
|
||||
using var discovery = new CultMeshDiscoveryService(
|
||||
new[] { new StaticRouteSource(clock, descriptor) },
|
||||
|
|
@ -102,22 +102,22 @@ public sealed class CultMeshSessionManagerTests
|
|||
attempts.Add(candidate.Endpoint);
|
||||
return Task.FromResult<ICultNetSchemaClient>(new FakeSchemaClient());
|
||||
});
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Documents);
|
||||
|
||||
session.State.Path!.Endpoint.Should().Be("rudp://authority:3076");
|
||||
attempts.Should().Equal("rudp://authority:3076");
|
||||
session.State.Path!.Endpoint.Should().Be("rudp://127.0.0.6:3076");
|
||||
attempts.Should().Equal("rudp://127.0.0.6:3076");
|
||||
}
|
||||
|
||||
[Test]
|
||||
public void Connect_RejectsRouteWhoseNativeIdentityDoesNotMatchTarget()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://decoy:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" });
|
||||
var connector = new FakeConnector((_, _) =>
|
||||
Task.FromResult<ICultNetSchemaClient>(new NativeMismatchClient()));
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
|
||||
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
|
||||
|
|
@ -129,10 +129,10 @@ public sealed class CultMeshSessionManagerTests
|
|||
public void Connect_RejectsPortableHandshakeFromWrongRuntime()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://decoy:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.5:3076" });
|
||||
var connector = new FakeConnector((_, _) =>
|
||||
Task.FromResult<ICultNetSchemaClient>(new HandshakeSchemaClient("decoy-daemon")));
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
|
||||
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
|
||||
|
|
@ -148,7 +148,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
"Aetheria",
|
||||
CultMeshVerseAuthorityModel.OperatorCluster,
|
||||
new CultMeshVerseCompatibility("cultmesh.v0", "rules"),
|
||||
discoveryEndpoints: new[] { "rudp://shared:3076" },
|
||||
discoveryEndpoints: new[] { "rudp://127.0.0.7:3076" },
|
||||
authorityRuntimeIds: new[] { "authority-a", "authority-b" });
|
||||
|
||||
action.Should().Throw<InvalidOperationException>().WithMessage("*ambiguous*");
|
||||
|
|
@ -158,7 +158,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task Connect_CallerCancellationDoesNotCancelSharedAttempt()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
|
||||
var release = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously);
|
||||
var connector = new FakeConnector(async (_, token) =>
|
||||
{
|
||||
|
|
@ -166,7 +166,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
await release.Task;
|
||||
return new FakeSchemaClient();
|
||||
});
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
using var cancellation = new CancellationTokenSource();
|
||||
var target = Target;
|
||||
|
||||
|
|
@ -185,7 +185,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task BackgroundFailureMigratesSameSessionAcrossEndpointRotation()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
var route = "rudp://first:3076";
|
||||
var route = "rudp://127.0.0.8:3076";
|
||||
using var discovery = Discovery(clock, () => new[] { route }, TimeSpan.FromSeconds(5));
|
||||
var clients = new List<FakeSchemaClient>();
|
||||
var connector = new FakeConnector((_, _) =>
|
||||
|
|
@ -194,7 +194,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
clients.Add(client);
|
||||
return Task.FromResult<ICultNetSchemaClient>(client);
|
||||
});
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
var target = Target;
|
||||
var first = await manager.ConnectAsync(target, CultMeshProtocols.Documents);
|
||||
var states = new List<CultMeshSessionStatus>();
|
||||
|
|
@ -204,14 +204,14 @@ public sealed class CultMeshSessionManagerTests
|
|||
schemaLease.OnCultNet<CultNetErrorMessage>(_ => deliveries++);
|
||||
clients[0].Emit(new CultNetErrorMessage { Error = "before rotation" });
|
||||
|
||||
route = "rudp://second:3076";
|
||||
route = "rudp://127.0.0.9:3076";
|
||||
clients[0].Fail(new IOException("partition"));
|
||||
await WaitUntilAsync(() => first.State.Status == CultMeshSessionStatus.Online && connector.ConnectCount == 2);
|
||||
var second = await manager.ConnectAsync(target, CultMeshProtocols.Documents);
|
||||
clients[1].Emit(new CultNetErrorMessage { Error = "after rotation" });
|
||||
|
||||
second.Should().BeSameAs(first);
|
||||
second.State.Path!.Endpoint.Should().Be("rudp://second:3076");
|
||||
second.State.Path!.Endpoint.Should().Be("rudp://127.0.0.9:3076");
|
||||
states.Should().ContainInOrder(CultMeshSessionStatus.Reconnecting, CultMeshSessionStatus.Online);
|
||||
connector.ConnectCount.Should().Be(2);
|
||||
clients[0].DisposeCount.Should().Be(1);
|
||||
|
|
@ -238,8 +238,8 @@ public sealed class CultMeshSessionManagerTests
|
|||
public void Connect_ReportsUnsupportedPathPrecisely()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "quic://unimplemented:443" });
|
||||
using var manager = new CultMeshSessionManager(discovery, Array.Empty<ICultMeshTransportConnector>(), new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var discovery = Discovery(clock, () => new[] { "quic://127.0.0.10:443" });
|
||||
using var manager = new CultMeshSessionManager(discovery, Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
|
||||
var error = Assert.ThrowsAsync<CultMeshSessionException>(() =>
|
||||
manager.ConnectAsync(Target, CultMeshProtocols.Documents));
|
||||
|
|
@ -253,10 +253,10 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task SnapshotSessionsBorrowOneManagedChannelAndDisposeRegistrationsIndependently()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
|
||||
var client = new RespondingSchemaClient();
|
||||
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
var target = Target;
|
||||
using var first = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions());
|
||||
using var second = await CultMeshSnapshotSession.ConnectAsync(manager, target, new CultMeshSnapshotRequestOptions());
|
||||
|
|
@ -274,10 +274,10 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task PeerExchangeBorrowsManagedProtocolSessionAcrossRequests()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
|
||||
var client = new PeerExchangeSchemaClient();
|
||||
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
var exchange = new CultMeshPeerExchangeClient(new CultMeshPeerExchangeClientOptions { Sessions = manager, Clock = clock });
|
||||
var target = Target;
|
||||
|
||||
|
|
@ -294,10 +294,10 @@ public sealed class CultMeshSessionManagerTests
|
|||
public async Task SchemaClientLeasesDisposeHandlersWithoutClosingSession()
|
||||
{
|
||||
var clock = new ManualSessionClock();
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://direct:3076" });
|
||||
using var discovery = Discovery(clock, () => new[] { "rudp://127.0.0.2:3076" });
|
||||
var client = new LeaseSchemaClient();
|
||||
var connector = new FakeConnector((_, _) => Task.FromResult<ICultNetSchemaClient>(client));
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, new CultMeshSessionManagerOptions { Clock = clock });
|
||||
using var manager = new CultMeshSessionManager(discovery, new[] { connector }, CultMeshTestTrust.LocalSessionsWithClock(clock));
|
||||
var session = await manager.ConnectAsync(Target, CultMeshProtocols.Subscriptions);
|
||||
using var first = session.OpenSchemaClient();
|
||||
using var second = session.OpenSchemaClient();
|
||||
|
|
@ -363,8 +363,8 @@ public sealed class CultMeshSessionManagerTests
|
|||
var expires = _clock.UtcNow.AddMinutes(1);
|
||||
return Task.FromResult<IReadOnlyList<CultMeshDiscoveryObservation>>(new[]
|
||||
{
|
||||
Observation("norn", "rudp://norn:3076", expires),
|
||||
Observation("aetheria", "rudp://aetheria:3076", expires)
|
||||
Observation("norn", "rudp://127.0.0.11:3076", expires),
|
||||
Observation("aetheria", "rudp://127.0.0.1:3076", expires)
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -544,7 +544,7 @@ public sealed class CultMeshSessionManagerTests
|
|||
MessageId = request.MessageId,
|
||||
Peers = new[]
|
||||
{
|
||||
new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://peer-a:3076" }).ToMessage()
|
||||
new CultMeshPeerCard("peer-a", request.VerseId, new[] { "rudp://127.0.0.12:3076" }).ToMessage()
|
||||
}
|
||||
};
|
||||
foreach (var handler in _handlers.ToArray()) handler(response);
|
||||
|
|
|
|||
18
tests/GameCult.Mesh.Tests/CultMeshTestTrust.cs
Normal file
18
tests/GameCult.Mesh.Tests/CultMeshTestTrust.cs
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
namespace GameCult.Mesh.Tests;
|
||||
|
||||
internal static class CultMeshTestTrust
|
||||
{
|
||||
internal static CultMeshAuthorityTrustPolicy LocalPolicy { get; } =
|
||||
new(CultMeshAuthorityTrustMode.LocalDevelopment);
|
||||
|
||||
internal static CultMeshSessionManagerOptions LocalSessions => new()
|
||||
{
|
||||
Trust = LocalPolicy
|
||||
};
|
||||
|
||||
internal static CultMeshSessionManagerOptions LocalSessionsWithClock(ICultMeshClock clock) => new()
|
||||
{
|
||||
Clock = clock,
|
||||
Trust = LocalPolicy
|
||||
};
|
||||
}
|
||||
|
|
@ -44,7 +44,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
{
|
||||
legacy,
|
||||
new CultMeshTcpSchemaTransportConnector()
|
||||
});
|
||||
}, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
var session = await sessions.ConnectAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"),
|
||||
|
|
@ -91,7 +91,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
{
|
||||
legacy,
|
||||
new CultMeshTcpContentTransportConnector()
|
||||
});
|
||||
}, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
var session = await sessions.ConnectContentAsync(new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
using var destination = new MemoryStream();
|
||||
|
|
@ -111,7 +111,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
});
|
||||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>());
|
||||
Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessions);
|
||||
|
||||
Func<Task> connect = async () => await sessions.ConnectContentAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
|
|
@ -132,7 +132,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
Array.Empty<ICultMeshContentTransportConnector>(),
|
||||
new ICultMeshRealtimeTransportConnector[] { connector });
|
||||
new ICultMeshRealtimeTransportConnector[] { connector }, CultMeshTestTrust.LocalSessions);
|
||||
var session = await sessions.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
var sent = new CultMeshRealtimeFrame
|
||||
|
|
@ -163,7 +163,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
});
|
||||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>());
|
||||
Array.Empty<ICultMeshTransportConnector>(), CultMeshTestTrust.LocalSessions);
|
||||
|
||||
Func<Task> connect = async () => await sessions.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
|
|
@ -183,7 +183,7 @@ public sealed class CultMeshTransportModularityTests
|
|||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
Array.Empty<ICultMeshContentTransportConnector>(),
|
||||
new ICultMeshRealtimeTransportConnector[] { new UnverifiedRealtimeConnector() });
|
||||
new ICultMeshRealtimeTransportConnector[] { new UnverifiedRealtimeConnector() }, CultMeshTestTrust.LocalSessions);
|
||||
|
||||
Func<Task> connect = async () => await sessions.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget("aetheria", "aetheria.daemon"));
|
||||
|
|
|
|||
|
|
@ -201,7 +201,8 @@ static async Task<StateResult> MeasureCultMeshQuicStateAsync(int payloadBytes, i
|
|||
[new CultMeshQuicRealtimeTransportConnector(new CultMeshQuicRealtimeConnectorOptions
|
||||
{
|
||||
ValidateProviderCertificate = (_, _, _, _) => true
|
||||
})]);
|
||||
})],
|
||||
LocalDevelopmentSessions());
|
||||
var session = await sessions.ConnectRealtimeAsync(
|
||||
new CultMeshSessionTarget("parity", "service:parity.provider"));
|
||||
var deadline = DateTime.UtcNow.AddSeconds(5);
|
||||
|
|
@ -363,7 +364,8 @@ static async Task<Result> MeasureCultMeshRudpAsync(byte[] payload)
|
|||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
[new CultMeshLegacyRudpContentTransportConnector(
|
||||
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })]);
|
||||
new CultMeshLegacyRudpContentTransportOptions { ResponseTimeout = TimeSpan.FromMinutes(2) })],
|
||||
LocalDevelopmentSessions());
|
||||
var provider = new CultMeshSessionContentProvider(
|
||||
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
|
||||
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
|
||||
|
|
@ -411,7 +413,8 @@ static async Task<Result> MeasureCultMeshTcpAsync(byte[] payload)
|
|||
using var sessions = new CultMeshSessionManager(
|
||||
discovery,
|
||||
Array.Empty<ICultMeshTransportConnector>(),
|
||||
[new CultMeshTcpContentTransportConnector()]);
|
||||
[new CultMeshTcpContentTransportConnector()],
|
||||
LocalDevelopmentSessions());
|
||||
var provider = new CultMeshSessionContentProvider(
|
||||
"parity.provider", sessions, new CultMeshSessionTarget("parity", "service:parity.provider"));
|
||||
var directory = Path.Combine(Path.GetTempPath(), "cultmesh-transport-parity", Guid.NewGuid().ToString("N"));
|
||||
|
|
@ -456,6 +459,11 @@ static X509Certificate2 CreateCertificate()
|
|||
static string TempPath(string plane) =>
|
||||
Path.Combine(Path.GetTempPath(), $"cultmesh-parity-{plane}-{Guid.NewGuid():N}.body");
|
||||
|
||||
static CultMeshSessionManagerOptions LocalDevelopmentSessions() => new()
|
||||
{
|
||||
Trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.LocalDevelopment)
|
||||
};
|
||||
|
||||
static Socket BindUdpSocket()
|
||||
{
|
||||
var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp);
|
||||
|
|
|
|||
Loading…
Reference in a new issue