diff --git a/contracts/cultmesh/authority-route-vectors.json b/contracts/cultmesh/authority-route-vectors.json new file mode 100644 index 00000000..76ec61f2 --- /dev/null +++ b/contracts/cultmesh/authority-route-vectors.json @@ -0,0 +1,44 @@ +{ + "nowUnixMilliseconds": 1800000000000, + "odinRoot": { + "keyId": "odin-vector", + "x": "shJLGtmRrW6D87VQKv0xYQekLIfEkeN7dVawwpmIKWM=", + "y": "iR6Gx3aVCghzSyo7TqXqr+1397L73KDNZuzN2cZ55IE=" + }, + "route": { + "verseId": "aetheria", + "authorityRuntimeId": "aetheria-daemon", + "endpoint": "wss://provider.example/mesh", + "protocolIds": [ + "cultmesh.content.v1", + "cultmesh.documents.v1" + ], + "priority": 7, + "generation": "vector-generation-1", + "certificate": { + "providerKey": { + "keyId": "provider-vector", + "x": "6XIvgx3W1q7lJFEV0yYbeKWNFgssq54BHBuh9gCwcH8=", + "y": "oi1FiKraueLic+VemzOx18MNwG6thmZMUWeqNw6vNOk=" + }, + "odinKeyId": "odin-vector", + "issuedAtUnixMilliseconds": 1799999940000, + "expiresAtUnixMilliseconds": 1800003600000, + "signature": "ZHuELFuCNNfbJt1T2mx1D9ZHXyxBedacDetmryMYhWKQxfROncltymkmL5KXl+1h8tDK6NytJCGy+7mYlb/DMA==" + } + }, + "sessionProof": { + "request": { + "schemaVersion": "cultmesh.session_open.v2", + "messageId": "vector-message-1", + "sourceRuntimeId": "vector-consumer", + "verseId": "aetheria", + "authorityRuntimeId": "aetheria-daemon", + "protocolId": "cultmesh.documents.v1", + "routeGeneration": "vector-generation-1", + "clientNonce": "VczqjH8NdBaOb+rh8xb6P0xZjWD1el2ReNB8Vbp52ko=" + }, + "providerKeyId": "provider-vector", + "providerSignature": "6kGxSu/pwcR4lzh/7Av9nhblVIwIwVFzB7kY34vVvkpaFAbAxt8mWqIyZDTftE28G1g+NL4Ea41C3QsuImmkaA==" + } +} diff --git a/packages/cultnet-ts/package.json b/packages/cultnet-ts/package.json index 2f4b74f1..d34278f5 100644 --- a/packages/cultnet-ts/package.json +++ b/packages/cultnet-ts/package.json @@ -14,6 +14,11 @@ "types": "./dist/contracts.d.ts", "require": "./dist/contracts.js", "default": "./dist/contracts.js" + }, + "./authority": { + "types": "./dist/cultmesh-authority.d.ts", + "require": "./dist/cultmesh-authority.js", + "default": "./dist/cultmesh-authority.js" } }, "files": [ diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts new file mode 100644 index 00000000..c579f347 --- /dev/null +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -0,0 +1,192 @@ +// The one TypeScript implementation of CultMesh route verification. +// +// Browser-safe by construction: only `crypto.subtle`, `TextEncoder`, `atob`, +// `btoa`, `URL`, `DataView` and `Uint8Array`. No `node:` import. The browser +// WebSocket client and the Node realtime consumer verify the same route bytes +// through the same functions; the C# reference is +// `src/GameCult.Mesh/CultMeshAuthorityProof.cs`. + +import type { CultMeshSessionOpenMessage } from "./contracts"; + +export interface CultMeshAuthorityIdentity { + verseId: string; + authorityRuntimeId: string; +} + +export interface CultMeshAuthorityRouteView extends CultMeshAuthorityIdentity { + endpoint: string; + protocolId?: string; + protocolIds?: readonly string[]; + priority?: number; + generation: string; + certificate?: CultMeshAuthorityRouteCertificate; +} + +export interface CultMeshP256PublicKey { + keyId: string; + x: string; + y: string; +} + +export interface CultMeshAuthorityRouteCertificate { + providerKey: CultMeshP256PublicKey; + odinKeyId: string; + issuedAtUnixMilliseconds: number; + expiresAtUnixMilliseconds: number; + signature: string; +} + +export type CultMeshAuthorityTrustMode = "authenticated-remote" | "local-development"; + +export interface CultMeshAuthorityTrustPolicy { + mode: CultMeshAuthorityTrustMode; + odinRoots?: readonly CultMeshP256PublicKey[]; + now?: () => number; +} + +export async function verifyAuthorityRoute( + route: CultMeshAuthorityRouteView, + trust: CultMeshAuthorityTrustPolicy, +): Promise { + 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."); + } + if (!isProtectedEndpoint(route.endpoint) && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { + throw new Error("Authenticated remote CultMesh routes require TLS or QUIC 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."); + } +} + +/** Verifies the provider's session proof over the accepted handshake. */ +export function verifyProviderSessionProof( + request: CultMeshSessionOpenMessage, + endpoint: string, + providerKey: CultMeshP256PublicKey, + signatureBase64: string, +): Promise { + return verifyP256(providerKey, canonicalSession(request, endpoint), signatureBase64); +} + +export function canonicalRoute(route: CultMeshAuthorityRouteView): 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(""), + String(route.priority ?? 0), + route.generation, + certificate.providerKey.keyId, + certificate.providerKey.x, + certificate.providerKey.y, + certificate.odinKeyId, + String(certificate.issuedAtUnixMilliseconds), + String(certificate.expiresAtUnixMilliseconds), + ); +} + +export 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, + ); +} + +export 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; +} + +export async function verifyP256( + key: CultMeshP256PublicKey, + payload: Uint8Array, + signatureBase64: string, +): Promise { + 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; + } +} + +export 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; + } +} + +/** The C# rule (`CultMeshAuthorityProof.cs`, `IsProtected`): wss, https, or any scheme containing "quic". */ +export function isProtectedEndpoint(value: string): boolean { + let scheme: string; + try { + scheme = new URL(value).protocol.slice(0, -1).toLowerCase(); + } catch { + return false; + } + return scheme === "wss" || scheme === "https" || scheme.includes("quic"); +} + +export function bytesToBase64(bytes: Uint8Array): string { + let binary = ""; + for (let offset = 0; offset < bytes.length; offset += 0x8000) { + binary += String.fromCharCode(...bytes.subarray(offset, offset + 0x8000)); + } + return btoa(binary); +} + +export function base64ToBytes(value: string): Uint8Array { + const binary = atob(value); + return Uint8Array.from(binary, character => character.charCodeAt(0)); +} diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index 140526c5..e9260122 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -62,6 +62,24 @@ export { type GhostlightVariableMap, type GhostlightWorldState, } from "./contracts"; +export { + base64ToBytes, + bytesToBase64, + canonicalFields, + canonicalRoute, + canonicalSession, + isLoopbackEndpoint, + isProtectedEndpoint, + verifyAuthorityRoute, + verifyP256, + verifyProviderSessionProof, + type CultMeshAuthorityIdentity, + type CultMeshAuthorityRouteCertificate, + type CultMeshAuthorityRouteView, + type CultMeshAuthorityTrustMode, + type CultMeshAuthorityTrustPolicy, + type CultMeshP256PublicKey, +} from "./cultmesh-authority"; export { encodeFrame, LengthPrefixedMessageFramer } from "./framing"; export { CultNetPeer, createTcpFramedCultNetPeer, type CultNetPeerEvents, type CultNetPeerSchemaCatalogRequestOptions, type CultNetPeerShardCatalogRequestOptions, type TcpFramedCultNetPeerOptions } from "./peer"; export { diff --git a/packages/cultnet-ts/test/cultmesh-authority.test.ts b/packages/cultnet-ts/test/cultmesh-authority.test.ts new file mode 100644 index 00000000..cb3a62da --- /dev/null +++ b/packages/cultnet-ts/test/cultmesh-authority.test.ts @@ -0,0 +1,238 @@ +// The shared CultMesh route verifier, pinned rule by rule. +// +// Routes here are signed with `node:crypto` and verified through the shared +// module. The transcript bytes themselves are pinned by +// `contracts/cultmesh/authority-route-vectors.json`, which the C# reference +// writes (`CultMeshAuthorityProofTests.AuthorityRouteVectorsAreSharedWithTypeScript` +// under CULTMESH_WRITE_VECTORS=1); a fixture authored on this side alone would +// only agree with itself. + +import test from "node:test"; +import assert from "node:assert/strict"; +import { generateKeyPairSync, sign, type KeyObject } from "node:crypto"; +import { readFileSync } from "node:fs"; +import { join } from "node:path"; + +import type { CultMeshSessionOpenMessage } from "../src/contracts"; +import { + base64ToBytes, + bytesToBase64, + canonicalRoute, + canonicalSession, + isLoopbackEndpoint, + isProtectedEndpoint, + verifyAuthorityRoute, + verifyP256, + verifyProviderSessionProof, + type CultMeshAuthorityRouteView, + type CultMeshP256PublicKey, +} from "../src/cultmesh-authority"; + +const NOW = 1_800_000_000_000; +const VECTORS_PATH = join(__dirname, "..", "..", "..", "..", "contracts", "cultmesh", "authority-route-vectors.json"); + +interface Vectors { + nowUnixMilliseconds: number; + odinRoot: CultMeshP256PublicKey; + route: CultMeshAuthorityRouteView; + sessionProof: { + request: CultMeshSessionOpenMessage; + providerKeyId: string; + providerSignature: string; + }; +} + +function keyPair(keyId: string): { privateKey: KeyObject; publicKey: CultMeshP256PublicKey } { + const pair = generateKeyPairSync("ec", { namedCurve: "P-256" }); + const jwk = pair.publicKey.export({ format: "jwk" }); + return { + privateKey: pair.privateKey, + publicKey: { + keyId, + x: Buffer.from(jwk.x!, "base64url").toString("base64"), + y: Buffer.from(jwk.y!, "base64url").toString("base64"), + }, + }; +} + +function signP1363(privateKey: KeyObject, payload: Uint8Array): string { + return sign("sha256", payload, { key: privateKey, dsaEncoding: "ieee-p1363" }).toString("base64"); +} + +function signedRoute(endpoint = "wss://provider.example/mesh") { + const odin = keyPair("odin-root-1"); + const provider = keyPair("provider-1"); + const unsigned: CultMeshAuthorityRouteView = { + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + endpoint, + protocolId: "cultmesh.documents.v1", + protocolIds: ["cultmesh.documents.v1"], + priority: 0, + generation: "signed-generation-1", + certificate: { + providerKey: provider.publicKey, + odinKeyId: odin.publicKey.keyId, + issuedAtUnixMilliseconds: NOW - 60_000, + expiresAtUnixMilliseconds: NOW + 3_600_000, + signature: "", + }, + }; + const route: CultMeshAuthorityRouteView = { + ...unsigned, + certificate: { ...unsigned.certificate!, signature: signP1363(odin.privateKey, canonicalRoute(unsigned)) }, + }; + return { route, odin, provider }; +} + +function request(clientNonce = "nonce-a"): CultMeshSessionOpenMessage { + return { + schemaVersion: "cultmesh.session_open.v2", + messageId: "message-1", + sourceRuntimeId: "consumer-1", + verseId: "sample.counter", + authorityRuntimeId: "sample.counter-daemon", + protocolId: "cultmesh.documents.v1", + routeGeneration: "signed-generation-1", + clientNonce, + }; +} + +const remoteTrust = (odinRoot: CultMeshP256PublicKey, now: number = NOW) => + ({ mode: "authenticated-remote" as const, odinRoots: [odinRoot], now: () => now }); + +test("a signed remote route verifies against its Odin root", async () => { + const { route, odin } = signedRoute(); + await verifyAuthorityRoute(route, remoteTrust(odin.publicKey)); +}); + +test("an unsigned remote route is refused in both trust modes", async () => { + const { route } = signedRoute(); + const unsigned = { ...route, certificate: undefined }; + await assert.rejects(verifyAuthorityRoute(unsigned, { mode: "authenticated-remote" }), /Odin-signed authority certificate/); + await assert.rejects(verifyAuthorityRoute(unsigned, { mode: "local-development" }), /Odin-signed authority certificate/); +}); + +test("an unsigned loopback route is accepted only under local-development", async () => { + const { route } = signedRoute("ws://127.0.0.1:4050/mesh"); + const unsigned = { ...route, certificate: undefined }; + await verifyAuthorityRoute(unsigned, { mode: "local-development" }); + await assert.rejects(verifyAuthorityRoute(unsigned, { mode: "authenticated-remote" }), /Odin-signed authority certificate/); +}); + +test("a certified route on an unprotected scheme is refused unless it is loopback under local-development", async () => { + const remote = signedRoute("ws://provider.example/mesh"); + await assert.rejects(verifyAuthorityRoute(remote.route, remoteTrust(remote.odin.publicKey)), /channel protection/); + const loopback = signedRoute("ws://localhost:4050/mesh"); + await assert.rejects(verifyAuthorityRoute(loopback.route, remoteTrust(loopback.odin.publicKey)), /channel protection/); + await verifyAuthorityRoute(loopback.route, { ...remoteTrust(loopback.odin.publicKey), mode: "local-development" }); +}); + +test("the validity window is half-open: issuedAt inclusive, expiresAt exclusive", async () => { + const { route, odin } = signedRoute(); + const certificate = route.certificate!; + await verifyAuthorityRoute(route, remoteTrust(odin.publicKey, certificate.issuedAtUnixMilliseconds)); + await verifyAuthorityRoute(route, remoteTrust(odin.publicKey, certificate.expiresAtUnixMilliseconds - 1)); + await assert.rejects( + verifyAuthorityRoute(route, remoteTrust(odin.publicKey, certificate.expiresAtUnixMilliseconds)), + /not currently valid/, + ); + await assert.rejects( + verifyAuthorityRoute(route, remoteTrust(odin.publicKey, certificate.issuedAtUnixMilliseconds - 1)), + /not currently valid/, + ); +}); + +test("an Odin root the consumer does not trust is refused before any signature check", async () => { + const { route } = signedRoute(); + const stranger = keyPair("odin-root-1"); + await assert.rejects(verifyAuthorityRoute(route, { mode: "authenticated-remote", odinRoots: [], now: () => NOW }), /is not trusted/); + await assert.rejects( + verifyAuthorityRoute(route, { mode: "authenticated-remote", odinRoots: [{ ...stranger.publicKey, keyId: "other-root" }], now: () => NOW }), + /is not trusted/, + ); + await assert.rejects(verifyAuthorityRoute(route, remoteTrust(stranger.publicKey)), /signature is invalid/); +}); + +test("a mutated endpoint invalidates the Odin signature", async () => { + const { route, odin } = signedRoute(); + await assert.rejects( + verifyAuthorityRoute({ ...route, endpoint: "wss://evil.example/mesh" }, remoteTrust(odin.publicKey)), + /signature is invalid/, + ); +}); + +test("a signature that is not 64 P1363 bytes is refused", async () => { + const { route, odin } = signedRoute(); + const bytes = base64ToBytes(route.certificate!.signature); + for (const wrong of [bytes.subarray(0, 63), new Uint8Array([...bytes, 0])]) { + await assert.rejects( + verifyAuthorityRoute( + { ...route, certificate: { ...route.certificate!, signature: bytesToBase64(wrong) } }, + remoteTrust(odin.publicKey), + ), + /signature is invalid/, + ); + } + assert.equal(await verifyP256(odin.publicKey, canonicalRoute(route), "not base64!"), false); +}); + +test("the provider session proof binds the nonce, the endpoint and the certified key", async () => { + const { route, provider } = signedRoute(); + const open = request(); + const signature = signP1363(provider.privateKey, canonicalSession(open, route.endpoint)); + assert.equal(await verifyProviderSessionProof(open, route.endpoint, provider.publicKey, signature), true); + assert.equal(await verifyProviderSessionProof(request("nonce-b"), route.endpoint, provider.publicKey, signature), false); + assert.equal(await verifyProviderSessionProof(open, "wss://evil.example/mesh", provider.publicKey, signature), false); + assert.equal(await verifyProviderSessionProof(open, route.endpoint, keyPair("provider-1").publicKey, signature), false); +}); + +test("isProtectedEndpoint is the C# rule: wss, https, or any scheme containing quic", () => { + for (const accepted of ["wss://provider.example/mesh", "https://provider.example/content", "cultmesh-state+quic://provider.example:4433"]) { + assert.equal(isProtectedEndpoint(accepted), true, accepted); + } + for (const refused of ["ws://provider.example/mesh", "cultnet+tcp://provider.example:3076", "not a url"]) { + assert.equal(isProtectedEndpoint(refused), false, refused); + } +}); + +test("isLoopbackEndpoint recognises localhost, 127.0.0.1 and ::1 only", () => { + for (const loopback of ["ws://localhost:4050/mesh", "ws://127.0.0.1:4050/mesh", "ws://[::1]:4050/mesh", "wss://LOCALHOST/mesh"]) { + assert.equal(isLoopbackEndpoint(loopback), true, loopback); + } + for (const remote of ["ws://192.0.2.10:4050/mesh", "wss://provider.example/mesh", "ws://127.0.0.2/mesh", "not a url"]) { + assert.equal(isLoopbackEndpoint(remote), false, remote); + } +}); + +test("base64 helpers round-trip across the 32 KiB chunk boundary", () => { + const bytes = new Uint8Array(70_000).map((_, index) => index % 251); + assert.deepEqual(base64ToBytes(bytesToBase64(bytes)), bytes); +}); + +test("the C# reference vectors verify through the shared module", async () => { + const vectors = JSON.parse(readFileSync(VECTORS_PATH, "utf8")) as Vectors; + const trust = { mode: "authenticated-remote" as const, odinRoots: [vectors.odinRoot], now: () => vectors.nowUnixMilliseconds }; + await verifyAuthorityRoute(vectors.route, trust); + await assert.rejects(verifyAuthorityRoute({ ...vectors.route, priority: 8 }, trust), /signature is invalid/); + await assert.rejects( + verifyAuthorityRoute({ ...vectors.route, protocolIds: [...vectors.route.protocolIds!, "cultmesh.realtime.v1"] }, trust), + /signature is invalid/, + ); + + const proof = vectors.sessionProof; + assert.equal(proof.providerKeyId, vectors.route.certificate!.providerKey.keyId); + assert.equal( + await verifyProviderSessionProof(proof.request, vectors.route.endpoint, vectors.route.certificate!.providerKey, proof.providerSignature), + true, + ); + assert.equal( + await verifyProviderSessionProof({ ...proof.request, clientNonce: "replayed" }, vectors.route.endpoint, vectors.route.certificate!.providerKey, proof.providerSignature), + false, + ); +}); + +test("the shared module keeps the browser-safe surface: no node: import", () => { + const source = readFileSync(join(__dirname, "..", "..", "src", "cultmesh-authority.ts"), "utf8"); + assert.doesNotMatch(source, /from "node:|require\("node:/); +}); diff --git a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs index 42f02297..44c078cc 100644 --- a/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs +++ b/tests/GameCult.Mesh.Tests/CultMeshAuthorityProofTests.cs @@ -1,10 +1,12 @@ using System; using System.Collections.Generic; using System.IO; +using System.Linq; using System.Net; using System.Net.Http; using System.Security.Cryptography; using System.Text; +using System.Text.Json; using System.Threading; using System.Threading.Tasks; using FluentAssertions; @@ -225,6 +227,132 @@ public sealed class CultMeshAuthorityProofTests handler.RequestUri.Query.Should().Contain("chunkHash=").And.Contain("recordKey="); } + // Shared-vector check with `packages/cultnet-ts/src/cultmesh-authority.ts`. + // The C# reference signs a route and a session proof with fixed keys and + // writes them when CULTMESH_WRITE_VECTORS=1; otherwise it asserts the + // committed file still verifies here. The TypeScript test verifies the same + // file, so both runtimes are pinned to the same transcript bytes. + [Test] + public void AuthorityRouteVectorsAreSharedWithTypeScript() + { + var path = Path.Combine(RepoRoot(), "contracts", "cultmesh", "authority-route-vectors.json"); + using var odin = ECDsa.Create(); + odin.ImportFromPem(VectorOdinPrivateKeyPem); + using var provider = ECDsa.Create(); + provider.ImportFromPem(VectorProviderPrivateKeyPem); + var odinPublic = CultMeshEcdsaP256PublicKey.From("odin-vector", odin); + var providerPublic = CultMeshEcdsaP256PublicKey.From("provider-vector", provider); + var request = new GameCult.Networking.CultMeshSessionOpenMessage + { + MessageId = "vector-message-1", + SourceRuntimeId = "vector-consumer", + VerseId = "aetheria", + AuthorityRuntimeId = "aetheria-daemon", + ProtocolId = CultMeshProtocols.Documents.Value, + RouteGeneration = "vector-generation-1", + ClientNonce = Convert.ToBase64String(SHA256.HashData(Encoding.UTF8.GetBytes("vector-nonce"))) + }; + + if (Environment.GetEnvironmentVariable("CULTMESH_WRITE_VECTORS") == "1") + { + var signedRoute = CultMeshAuthorityProof.CreateSignedRoute( + "aetheria", "aetheria-daemon", "wss://provider.example/mesh", + new[] { CultMeshProtocols.Documents.Value, CultMeshProtocols.Content.Value }, 7, "vector-generation-1", + providerPublic, odinPublic.KeyId, Now.AddMinutes(-1), Now.AddHours(1), odin); + var signer = new CultMeshSessionProofSigner(signedRoute, provider); + Directory.CreateDirectory(Path.GetDirectoryName(path)!); + File.WriteAllText(path, JsonSerializer.Serialize(new + { + nowUnixMilliseconds = Now.ToUnixTimeMilliseconds(), + odinRoot = new { keyId = odinPublic.KeyId, x = odinPublic.X, y = odinPublic.Y }, + route = new + { + verseId = "aetheria", + authorityRuntimeId = signedRoute.AuthorityRuntimeId, + endpoint = signedRoute.Endpoint, + protocolIds = signedRoute.ProtocolIds, + priority = signedRoute.Priority, + generation = signedRoute.Generation, + certificate = new + { + providerKey = new { keyId = providerPublic.KeyId, x = providerPublic.X, y = providerPublic.Y }, + odinKeyId = signedRoute.Certificate!.OdinKeyId, + issuedAtUnixMilliseconds = signedRoute.Certificate.IssuedAtUnixMilliseconds, + expiresAtUnixMilliseconds = signedRoute.Certificate.ExpiresAtUnixMilliseconds, + signature = signedRoute.Certificate.Signature + } + }, + sessionProof = new + { + request = new + { + schemaVersion = request.SchemaVersion, + messageId = request.MessageId, + sourceRuntimeId = request.SourceRuntimeId, + verseId = request.VerseId, + authorityRuntimeId = request.AuthorityRuntimeId, + protocolId = request.ProtocolId, + routeGeneration = request.RouteGeneration, + clientNonce = request.ClientNonce + }, + providerKeyId = signer.ProviderKeyId, + providerSignature = signer.Sign(request) + } + }, new JsonSerializerOptions + { + WriteIndented = true, + Encoder = System.Text.Encodings.Web.JavaScriptEncoder.UnsafeRelaxedJsonEscaping + }) + "\n"); + } + + using var vectors = JsonDocument.Parse(File.ReadAllText(path)); + var rootElement = vectors.RootElement; + var routeElement = rootElement.GetProperty("route"); + var certificateElement = routeElement.GetProperty("certificate"); + var route = new CultMeshAuthorityRoute( + routeElement.GetProperty("authorityRuntimeId").GetString()!, + routeElement.GetProperty("endpoint").GetString()!, + routeElement.GetProperty("protocolIds").EnumerateArray().Select(value => value.GetString()!).ToArray(), + routeElement.GetProperty("priority").GetInt32(), + routeElement.GetProperty("generation").GetString(), + new CultMeshRouteCertificate( + providerPublic, + certificateElement.GetProperty("odinKeyId").GetString()!, + certificateElement.GetProperty("issuedAtUnixMilliseconds").GetInt64(), + certificateElement.GetProperty("expiresAtUnixMilliseconds").GetInt64(), + certificateElement.GetProperty("signature").GetString()!)); + var trust = new CultMeshAuthorityTrustPolicy(CultMeshAuthorityTrustMode.AuthenticatedRemote, new[] { odinPublic }); + var now = DateTimeOffset.FromUnixTimeMilliseconds(rootElement.GetProperty("nowUnixMilliseconds").GetInt64()); + var verseId = routeElement.GetProperty("verseId").GetString()!; + + trust.Validate(verseId, route, now); + var proofElement = rootElement.GetProperty("sessionProof"); + var accepted = Accepted(request, proofElement.GetProperty("providerSignature").GetString()!); + accepted.ProviderKeyId = proofElement.GetProperty("providerKeyId").GetString()!; + CultMeshAuthorityProof.VerifySessionProof(request, accepted, verseId, route, trust, now).Should().BeTrue(); + } + + private static string RepoRoot() + { + var directory = new DirectoryInfo(TestContext.CurrentContext.TestDirectory); + while (directory != null && !File.Exists(Path.Combine(directory.FullName, "CultLib.sln"))) + directory = directory.Parent; + return directory?.FullName ?? throw new InvalidOperationException("CultLib.sln was not found above the test directory."); + } + + // Fixed test keys. They sign only the committed vector and prove nothing else. + private const string VectorOdinPrivateKeyPem = @"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgHY0//QOheSwARzXE +iX/nGqSD7XFAy530QQqKeXPNxMChRANCAASyEksa2ZGtboPztVAq/TFhB6Qsh8SR +43t1VrDCmYgpY4kehsd2lQoIc0sqO06l6q/td/ey+9ygzWbszdnGeeSB +-----END PRIVATE KEY-----"; + + private const string VectorProviderPrivateKeyPem = @"-----BEGIN PRIVATE KEY----- +MIGHAgEAMBMGByqGSM49AgEGCCqGSM49AwEHBG0wawIBAQQgVC+9X4t+1HAiIxP4 +TEuAuBAY+3ryKK8SaitB0TqsPQqhRANCAATpci+DHdbWruUkURXTJht4pY0WCyyr +ngEcG6H2ALBwf6ItRYiq2rni4nPlXpszsdfDDcBurYZmTFFnqjcOrzTp +-----END PRIVATE KEY-----"; + private static CultMeshAuthorityRoute SignedRoute(ECDsa odin, ECDsa provider) => CultMeshAuthorityProof.CreateSignedRoute( "aetheria",