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:
parent
e3f56c183f
commit
a801e0a441
5 changed files with 58 additions and 9 deletions
|
|
@ -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.");
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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.");
|
||||
}
|
||||
|
|
|
|||
|
|
@ -70,6 +70,7 @@ export {
|
|||
canonicalSession,
|
||||
isLoopbackEndpoint,
|
||||
isProtectedEndpoint,
|
||||
isUnsignedCertificate,
|
||||
verifyAuthorityRoute,
|
||||
verifyP256,
|
||||
verifyProviderSessionProof,
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
Loading…
Reference in a new issue