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",