From a801e0a441573aa93bfb3f5776116dfd82e45fa0 Mon Sep 17 00:00:00 2001 From: Meta Vens Date: Wed, 16 Sep 2026 16:43:09 +0200 Subject: [PATCH] Share one reading of an unsigned certificate between verifier and browser The browser's local-development session-proof short-circuit tested `!route.certificate`, while the shared verifier already treated an empty or whitespace signature as unsigned, matching C# `Verify` (`:120`). A loopback route carrying a blank-signature certificate therefore passed the verifier and was then refused for lacking a provider key, where C# returns `IsLocalDevelopment` and skips the session proof. `isUnsignedCertificate` in `cultnet-ts/authority` is now the one owner of that reading; the verifier and the browser both call it. A browser test pins the local-development loopback open without a provider proof, the signed loopback certificate still demanding it, and the remote counterpart refused in both trust modes. The mutation runner gains two predicate mutations (whitespace treated as signed; only a missing certificate treated as unsigned), both killed by the existing whitespace-signature test in cultnet-ts. Co-Authored-By: Claude Fable 5.1 --- packages/cultmesh-browser/src/index.ts | 7 ++++- .../test/cultmesh-browser.test.ts | 31 +++++++++++++++++++ packages/cultnet-ts/src/cultmesh-authority.ts | 17 +++++++--- packages/cultnet-ts/src/index.ts | 1 + scripts/mutate-cultmesh-authority.mjs | 11 +++++-- 5 files changed, 58 insertions(+), 9 deletions(-) diff --git a/packages/cultmesh-browser/src/index.ts b/packages/cultmesh-browser/src/index.ts index 86d933d1..0398d313 100644 --- a/packages/cultmesh-browser/src/index.ts +++ b/packages/cultmesh-browser/src/index.ts @@ -19,6 +19,7 @@ import { base64ToBytes, bytesToBase64, isLoopbackEndpoint, + isUnsignedCertificate, verifyAuthorityRoute, verifyProviderSessionProof, type CultMeshAuthorityIdentity, @@ -912,7 +913,11 @@ async function validateSessionAcceptance( `expected '${request.verseId}/${request.authorityRuntimeId}/${protocolId}/${route.generation}'.`, ); } - if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && !route.certificate) return undefined; + // `CultMeshVerifiedAuthorityRoute.IsLocalDevelopment` in C#: an unsigned + // loopback route under local-development needs no provider session proof. + if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint) && isUnsignedCertificate(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."); diff --git a/packages/cultmesh-browser/test/cultmesh-browser.test.ts b/packages/cultmesh-browser/test/cultmesh-browser.test.ts index 6c169d93..77237bb9 100644 --- a/packages/cultmesh-browser/test/cultmesh-browser.test.ts +++ b/packages/cultmesh-browser/test/cultmesh-browser.test.ts @@ -236,6 +236,37 @@ test("browser client rejects a credential-free authority impersonator", async () ); }); +test("browser client opens an unsigned-certificate loopback session under local-development without a provider proof, as in C#", async () => { + const connect = (route: CultMeshBrowserRoute, trust: { mode: "local-development" | "authenticated-remote"; odinRoots: CultMeshBrowserP256PublicKey[] }) => + CultMeshBrowserClient.connect({ + ...route, + runtimeId: "browser-unsigned-certificate-test", + rendezvous: { resolve: async () => route }, + trust, + socketFactory: () => handshakeOnlySocket(route), + }); + const blank = (route: CultMeshBrowserRoute) => ({ ...route, certificate: { ...route.certificate!, signature: "" } }); + + const loopback = await createSignedRoute("ws://127.0.0.1:4501/mesh"); + const localTrust = { mode: "local-development" as const, odinRoots: [loopback.odinPublic] }; + const client = await connect(blank(loopback.route), localTrust); + assert.equal(client.state, "connected"); + await client.dispose(); + // A signed loopback certificate is an authenticated route even under + // local-development; the provider must still prove its key. + await assert.rejects(connect(loopback.route, localTrust), /did not prove possession/); + + const remote = await createSignedRoute("wss://provider.example/mesh"); + await assert.rejects( + connect(blank(remote.route), { mode: "local-development", odinRoots: [remote.odinPublic] }), + /Odin-signed authority certificate/, + ); + await assert.rejects( + connect(blank(remote.route), { mode: "authenticated-remote", odinRoots: [remote.odinPublic] }), + /Odin-signed authority certificate/, + ); +}); + 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; diff --git a/packages/cultnet-ts/src/cultmesh-authority.ts b/packages/cultnet-ts/src/cultmesh-authority.ts index fd0fcce6..40bcff1a 100644 --- a/packages/cultnet-ts/src/cultmesh-authority.ts +++ b/packages/cultnet-ts/src/cultmesh-authority.ts @@ -44,23 +44,30 @@ export interface CultMeshAuthorityTrustPolicy { now?: () => number; } +// A missing certificate, or one whose signature is empty or whitespace, is an +// unsigned route (`CultMeshRouteCertificate` trims the signature; `Verify` +// treats a whitespace signature as no certificate). The verifier and the +// browser's session-proof short-circuit share this one reading, as the C# +// `Verify` and `IsLocalDevelopment` do. +export function isUnsignedCertificate(certificate: CultMeshAuthorityRouteCertificate | undefined): boolean { + return !certificate || certificate.signature.trim() === ""; +} + // Rule for rule, this is `CultMeshAuthorityTrustPolicy.Verify` in the C# // reference, in the same order: unsigned, channel protection, root lookup, // validity window, signature. The same input yields the same refusal on both -// sides. A certificate whose signature is empty or whitespace is unsigned -// (`CultMeshRouteCertificate` trims the signature; `Verify` treats a -// whitespace signature as no certificate). +// sides. export async function verifyAuthorityRoute( route: CultMeshAuthorityRouteView, trust: CultMeshAuthorityTrustPolicy, ): Promise { const roots = trustedOdinRoots(trust); const certificate = route.certificate; - const signature = certificate?.signature.trim() ?? ""; - if (!certificate || signature === "") { + if (!certificate || isUnsignedCertificate(certificate)) { if (trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint)) return; throw new Error("Remote CultMesh routes require an Odin-signed authority certificate."); } + const signature = certificate.signature.trim(); if (!isProtectedEndpoint(route.endpoint) && !(trust.mode === "local-development" && isLoopbackEndpoint(route.endpoint))) { throw new Error("Authenticated remote CultMesh routes require TLS or QUIC channel protection."); } diff --git a/packages/cultnet-ts/src/index.ts b/packages/cultnet-ts/src/index.ts index e9260122..4987b177 100644 --- a/packages/cultnet-ts/src/index.ts +++ b/packages/cultnet-ts/src/index.ts @@ -70,6 +70,7 @@ export { canonicalSession, isLoopbackEndpoint, isProtectedEndpoint, + isUnsignedCertificate, verifyAuthorityRoute, verifyP256, verifyProviderSessionProof, diff --git a/scripts/mutate-cultmesh-authority.mjs b/scripts/mutate-cultmesh-authority.mjs index cb4cae16..6053cb14 100644 --- a/scripts/mutate-cultmesh-authority.mjs +++ b/scripts/mutate-cultmesh-authority.mjs @@ -58,9 +58,14 @@ const mutations = [ new: "const root = [...roots.values()][0];", }, { - rule: "empty or whitespace signature is an unsigned route", - old: 'if (!certificate || signature === "") {', - new: "if (!certificate) {", + rule: "isUnsignedCertificate treats a whitespace signature as unsigned", + old: 'return !certificate || certificate.signature.trim() === "";', + new: 'return !certificate || certificate.signature === "";', + }, + { + rule: "isUnsignedCertificate treats an empty signature as unsigned", + old: 'return !certificate || certificate.signature.trim() === "";', + new: "return !certificate;", }, { rule: "root lookup precedes the validity window",