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 <noreply@anthropic.com>
This commit is contained in:
Meta Vens 2026-09-16 16:43:09 +02:00
commit a801e0a441
5 changed files with 58 additions and 9 deletions

View file

@ -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.");

View file

@ -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;

View file

@ -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<void> {
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.");
}

View file

@ -70,6 +70,7 @@ export {
canonicalSession,
isLoopbackEndpoint,
isProtectedEndpoint,
isUnsignedCertificate,
verifyAuthorityRoute,
verifyP256,
verifyProviderSessionProof,

View file

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