Add the shared CultMesh route verifier to cultnet-ts
Cut 1 of the TypeScript QUIC realtime plane: one TypeScript implementation of Odin root lookup, the validity window, the canonical route and session transcripts, P-256/P1363 verification, loopback detection and the protected-scheme rule, published as `cultnet-ts/authority`. Browser-safe by construction: WebCrypto, TextEncoder, atob/btoa, URL, DataView; no node: import, and a test that says so. The protected-scheme rule is the C# rule (wss, https, any scheme containing "quic") rather than wss-only, so the module is not wrong for QUIC routes; the browser client still refuses non-ws(s) endpoints itself. The transcript bytes are pinned by contracts/cultmesh/authority-route- vectors.json, written by the C# reference under CULTMESH_WRITE_VECTORS=1 with fixed test keys and otherwise re-verified by both runtimes. The vector carries a route certificate and a provider session proof. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
parent
1c12136872
commit
e234a64ae9
6 changed files with 625 additions and 0 deletions
44
contracts/cultmesh/authority-route-vectors.json
Normal file
44
contracts/cultmesh/authority-route-vectors.json
Normal file
|
|
@ -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=="
|
||||
}
|
||||
}
|
||||
|
|
@ -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": [
|
||||
|
|
|
|||
192
packages/cultnet-ts/src/cultmesh-authority.ts
Normal file
192
packages/cultnet-ts/src/cultmesh-authority.ts
Normal file
|
|
@ -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<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.");
|
||||
}
|
||||
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<boolean> {
|
||||
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<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;
|
||||
}
|
||||
}
|
||||
|
||||
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));
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
238
packages/cultnet-ts/test/cultmesh-authority.test.ts
Normal file
238
packages/cultnet-ts/test/cultmesh-authority.test.ts
Normal file
|
|
@ -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:/);
|
||||
});
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue