From 218f60f828574983cb0c91c482aaf15b8dbf9143 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 10:21:40 +0200 Subject: [PATCH 01/12] fix(daemon): reject malformed quads with 4xx instead of 500; guard gossip-signer agentAddress (#306, #787) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit #306 — POST /api/knowledge-assets/:name/wm/write and #787 — POST /api/shared-memory/write (+ /shared-memory/conditional-write) only checked `Array.isArray(quads)`, so a string-shaped quad ("

.") slipped through and crashed the agent write path with a TypeError → HTTP 500. Added isWritableQuad (graph optional, unlike the publish path's isPublishQuad) and validate every quad at the route boundary, returning an actionable 400. #787 root cause — getWorkspaceGossipSigningAgent() called `record.agentAddress.toLowerCase()` unconditionally; a node-level key record (privateKey, no agentAddress) crashed it on every SWM write via that token. Guarded with optional chaining so such records fall through to the fallback signer. Verified: new test issue-306-787-write-quad-validation.test.ts — string quads → 4xx on both routes AND well-formed object quads still → 200 (no regression). cli write-path regression suites green (191 tests), agent gossip-signer test green. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-crypto.ts | 6 +- packages/cli/src/daemon/http-utils.ts | 20 +++ .../cli/src/daemon/routes/knowledge-assets.ts | 6 + packages/cli/src/daemon/routes/memory.ts | 8 + ...ssue-306-787-write-quad-validation.test.ts | 140 ++++++++++++++++++ 5 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 packages/cli/test/issue-306-787-write-quad-validation.test.ts diff --git a/packages/agent/src/dkg-agent-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index 6dfac85cb..e65485630 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -387,7 +387,11 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { for (const record of this.localAgents.values()) { if (!record.privateKey) continue; const signingRecord = { ...record, privateKey: record.privateKey }; - if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) { + // GH #787 — a node-level key record can carry a privateKey but no + // agentAddress (operational identity, not an agent). Guard the compare so + // it falls through to the fallback signer instead of throwing TypeError + // (`toLowerCase` of undefined → HTTP 500 on every SWM write via that token). + if (defaultAddress && record.agentAddress?.toLowerCase() === defaultAddress) { return signingRecord; } fallback ??= signingRecord; diff --git a/packages/cli/src/daemon/http-utils.ts b/packages/cli/src/daemon/http-utils.ts index 5c7eb65af..102769ef1 100644 --- a/packages/cli/src/daemon/http-utils.ts +++ b/packages/cli/src/daemon/http-utils.ts @@ -100,6 +100,26 @@ export function isPublishQuad(value: unknown): value is PublishQuad { ); } +/** + * GH #306 / #787 — shape guard for the WRITE routes (wm/write, + * shared-memory/write). Unlike {@link isPublishQuad} the `graph` term is + * OPTIONAL here: those routes legitimately accept `{subject,predicate,object}` + * and fill the graph internally. Without this guard, a string-shaped quad + * (e.g. an N-Quad line `"

."`) slips past a bare `Array.isArray` + * check and crashes the agent write path with a TypeError → HTTP 500 instead + * of an actionable 4xx. + */ +export function isWritableQuad(value: unknown): boolean { + if (!value || typeof value !== "object" || Array.isArray(value)) return false; + const v = value as Record; + return ( + typeof v.subject === "string" && + typeof v.predicate === "string" && + typeof v.object === "string" && + (v.graph === undefined || typeof v.graph === "string") + ); +} + function validatePublishQuadObjectTerms( label: string, quads: PublishQuad[], diff --git a/packages/cli/src/daemon/routes/knowledge-assets.ts b/packages/cli/src/daemon/routes/knowledge-assets.ts index be508df5c..e542af658 100644 --- a/packages/cli/src/daemon/routes/knowledge-assets.ts +++ b/packages/cli/src/daemon/routes/knowledge-assets.ts @@ -35,6 +35,7 @@ import { validateOptionalSubGraphName, validateRequiredContextGraphId, parsePublishRequestBody, + isWritableQuad, normalizeContextGraphIdOrUri, resolveRequiredWriteContextGraphId, } from "../http-utils.js"; @@ -938,6 +939,11 @@ export async function handleKnowledgeAssetsRoutes(ctx: RequestContext): Promise< if (layer === "wm") { if (verb === "write") { if (!Array.isArray(parsed.quads)) return jsonResponse(res, 400, { error: 'Missing "quads"' }); + // GH #306 — reject string-shaped / malformed quads here (4xx) instead of + // letting them crash the agent write path with a TypeError (HTTP 500). + if (!parsed.quads.every(isWritableQuad)) { + return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' }); + } // A bare write to a name that was never created used to fall through to // the legacy `/assertion/{addr}/{name}` graph and produce a KA that is // permanently 404 in the descriptor API (no `_meta` lifecycle record, diff --git a/packages/cli/src/daemon/routes/memory.ts b/packages/cli/src/daemon/routes/memory.ts index fd6fd4797..6b0ca4b5a 100644 --- a/packages/cli/src/daemon/routes/memory.ts +++ b/packages/cli/src/daemon/routes/memory.ts @@ -197,6 +197,7 @@ import { import { resolveNameToPeerId, isPublishQuad, + isWritableQuad, parsePublishRequestBody, jsonResponse, safeDecodeURIComponent, @@ -1641,6 +1642,10 @@ WHERE { const contextGraphId = parsed.contextGraphId; if (!quads?.length) return jsonResponse(res, 400, { error: 'Missing "quads"' }); + // GH #787 / #306 — reject string-shaped / malformed quads here (4xx) instead + // of crashing the SWM write path with a TypeError (HTTP 500). + if (!Array.isArray(quads) || !quads.every(isWritableQuad)) + return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' }); const resolvedContextGraphId = await resolveRequiredWriteContextGraphId( agent, contextGraphId, @@ -2210,6 +2215,9 @@ WHERE { const contextGraphId = parsed.contextGraphId; if (!quads?.length) return jsonResponse(res, 400, { error: 'Missing "quads"' }); + // GH #787 / #306 — reject string-shaped / malformed quads (4xx, not a 500 crash). + if (!Array.isArray(quads) || !quads.every(isWritableQuad)) + return jsonResponse(res, 400, { error: '"quads" must be an array of { subject, predicate, object } objects (graph optional); string-shaped quads are not accepted' }); const resolvedContextGraphId = await resolveRequiredWriteContextGraphId( agent, contextGraphId, diff --git a/packages/cli/test/issue-306-787-write-quad-validation.test.ts b/packages/cli/test/issue-306-787-write-quad-validation.test.ts new file mode 100644 index 000000000..5fcd62d51 --- /dev/null +++ b/packages/cli/test/issue-306-787-write-quad-validation.test.ts @@ -0,0 +1,140 @@ +/** + * GH #306 / #787 — write routes must reject malformed (string-shaped) quads with + * an actionable 4xx instead of crashing with a TypeError → HTTP 500. + * + * #787 — POST /api/shared-memory/write with N-Quad *string* quads → was 500 + * ("Cannot read properties of undefined (reading 'toLowerCase')"). + * https://github.com/OriginTrail/dkg/issues/787 + * #306 — POST /api/knowledge-assets/{name}/wm/write with string quads → was 500 + * ("Cannot use 'in' operator to search for 'graph' in

."). + * https://github.com/OriginTrail/dkg/issues/306 + * + * The fix validates quad shape at the route boundary (isWritableQuad) BEFORE the + * agent write path. This test also asserts the POSITIVE path — well-formed + * {subject,predicate,object} quads (graph optional) still succeed — so the + * validation can't regress valid writes. One real auth-enabled daemon against + * the cli suite's shared Hardhat node; no chain mocks. + */ +import { beforeAll, afterAll, describe, expect, it } from 'vitest'; +import { spawn, type ChildProcess } from 'node:child_process'; +import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises'; +import { existsSync } from 'node:fs'; +import { join, dirname } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { tmpdir } from 'node:os'; +import { ethers } from 'ethers'; +import { getSharedContext, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js'; + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const CLI_ENTRY = join(__dirname, '..', 'dist', 'cli.js'); +const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); + +interface Daemon { home: string; apiPort: number; child: ChildProcess; token: string; } +let daemon: Daemon | null = null; +const CG = 'wq-validation-cg'; + +async function startDaemon(): Promise { + if (!existsSync(CLI_ENTRY)) throw new Error(`CLI not built at ${CLI_ENTRY}. Run the package build first.`); + const home = await mkdtemp(join(tmpdir(), 'dkg-wq-validation-')); + const apiPort = 19760 + Math.floor(Math.random() * 180); + const listenPort = apiPort + 400; + const { rpcUrl, hubAddress } = getSharedContext(); + await writeFile(join(home, 'config.json'), JSON.stringify({ + name: 'wq-validation-test', apiPort, listenPort, apiHost: '127.0.0.1', nodeRole: 'edge', relay: 'none', + auth: { enabled: true }, + store: { backend: 'oxigraph-worker', options: { path: join(home, 'store.nq') } }, + chain: { type: 'evm', rpcUrl, hubAddress, chainId: 'evm:31337' }, contextGraphs: [], + })); + const coreOp = new ethers.Wallet(HARDHAT_KEYS.CORE_OP); + await writeFile(join(home, 'wallets.json'), + JSON.stringify({ wallets: [{ address: coreOp.address, privateKey: coreOp.privateKey }] }, null, 2) + '\n', { mode: 0o600 }); + const child = spawn('node', [CLI_ENTRY, 'daemon-worker'], { + env: { ...process.env, DKG_HOME: home, DKG_API_PORT: String(apiPort), DKG_NO_BLUE_GREEN: '1', DKG_DISABLE_TELEMETRY: '1' }, + stdio: 'ignore', + }); + for (let i = 0; i < 90; i++) { + if (child.exitCode !== null) throw new Error(`Daemon exited early (${child.exitCode})`); + try { if ((await fetch(`http://127.0.0.1:${apiPort}/api/status`)).ok) break; } catch { /* not ready */ } + await sleep(500); + if (i === 89) throw new Error('Daemon did not become ready within 45s'); + } + const raw = await readFile(join(home, 'auth.token'), 'utf-8'); + const token = raw.split('\n').map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith('#')); + if (!token) throw new Error('No auth token'); + return { home, apiPort, child, token }; +} + +const url = (p: string) => `http://127.0.0.1:${daemon!.apiPort}${p}`; +const headers = () => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${daemon!.token}` }); + +beforeAll(async () => { + daemon = await startDaemon(); + const res = await fetch(url('/api/context-graph/create'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ id: CG, name: 'WQ Validation CG', accessPolicy: 0 }), + }); + if (!res.ok) throw new Error(`CG create failed: ${res.status} ${await res.text()}`); +}, 120_000); + +afterAll(async () => { + if (daemon) { + daemon.child.kill('SIGTERM'); + await sleep(1500); + if (daemon.child.exitCode === null) daemon.child.kill('SIGKILL'); + await rm(daemon.home, { recursive: true, force: true }).catch(() => {}); + } +}); + +describe('GH #787 — POST /api/shared-memory/write quad-shape validation', () => { + it('returns 4xx (not 500) for N-Quad string-shaped quads', async () => { + const res = await fetch(url('/api/shared-memory/write'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, quads: [' "v" .'] }), + }); + expect(res.status).not.toBe(500); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('accepts well-formed object quads (regression: valid SWM write still succeeds)', async () => { + const res = await fetch(url('/api/shared-memory/write'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, quads: [ + { subject: 'urn:wq:s787', predicate: 'http://schema.org/name', object: '"ok787"' }, + ] }), + }); + expect(res.status, await res.text().catch(() => '')).toBe(200); + }); +}); + +describe('GH #306 — POST /api/knowledge-assets/{name}/wm/write quad-shape validation', () => { + it('returns 4xx (not 500) for N-Quad string-shaped quads', async () => { + const created = await fetch(url('/api/knowledge-assets'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, name: 'ka-306' }), + }); + expect(created.status, 'KA create precondition').toBeLessThan(300); + const res = await fetch(url('/api/knowledge-assets/ka-306/wm/write'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, quads: [' .'] }), + }); + expect(res.status).not.toBe(500); + expect(res.status).toBeGreaterThanOrEqual(400); + expect(res.status).toBeLessThan(500); + }); + + it('accepts well-formed object quads (regression: valid wm/write still succeeds)', async () => { + const created = await fetch(url('/api/knowledge-assets'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, name: 'ka-306-ok' }), + }); + expect(created.status).toBeLessThan(300); + const res = await fetch(url('/api/knowledge-assets/ka-306-ok/wm/write'), { + method: 'POST', headers: headers(), + body: JSON.stringify({ contextGraphId: CG, quads: [ + { subject: 'urn:wq:s306', predicate: 'http://schema.org/name', object: '"ok306"' }, + ] }), + }); + expect(res.status, await res.text().catch(() => '')).toBe(200); + }); +}); From 57d6f29838fd1254bd3f5f3c7ed084cb134ec9bf Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 10:52:49 +0200 Subject: [PATCH 02/12] fix(agent/swm): public CGs reach storage-ACK quorum via host-mode ingest (#1124) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Host-mode cores dropped a PUBLIC CG's plaintext SWM share at two gates in ingestSwmHostModeEnvelope — the isCiphertext sniff and the curated-agent authority check (which rejects 'no agent allowlist', i.e. exactly the public case). With no host holding the data, a public CG's storage-ACK quorum was unreachable (NO_DATA_IN_SWM). Private/curated CGs were unaffected (ciphertext + allowlist), which is why they worked while public CGs never published to VM. Fix opens BOTH gates, but ONLY for a CG positively confirmed public via the new isConfirmedPublicForHostMode helper: - Gate 1: a non-ciphertext envelope is dropped unless the CG is confirmed public (plaintext is the legitimate carrier for open CGs). - Gate 2: a 'no agent allowlist' verdict is accepted only for a confirmed public CG — the envelope is already verified SIGNED (the unsigned-envelope check runs first), and public host-mode storage stays bounded by the per-CG byte cap + registration economics (same safety net as the pre-reg fail-open). SECURITY: isConfirmedPublicForHostMode is biased so curated AND unknown both return false. A curated CG whose on-chain policy hasn't loaded yet (chain-event race — also surfaces as 'no agent allowlist') is therefore NEVER misclassified as public; it stays a drop and heals via member catchup. Curated + unknown behaviour is byte-for-byte unchanged. Verified: host-mode-public-ingest-1124.test.ts pins the classifier safety bias (confirmed-public→true; curated marker / private / unknown / throw → false). Full agent suite green (1668 passed); existing host-mode tests green (23). NOTE: end-to-end public-CG publish-to-VM quorum needs a host-mode sharded topology (non-member storage cores) to observe — see PR description for the testnet verification plan. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-swm-host.ts | 54 +++++++++- .../swm/host-mode-public-ingest-1124.test.ts | 98 +++++++++++++++++++ 2 files changed, 151 insertions(+), 1 deletion(-) create mode 100644 packages/agent/test/swm/host-mode-public-ingest-1124.test.ts diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index be44828d3..de1a1b42d 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -678,6 +678,33 @@ export class SwmHostModeMethods extends DKGAgentBase { } } + /** + * GH #1124 — DEFINITIVE public-CG check for the host-mode ingest gates. Unlike + * `!isCuratedForHostMode` (which treats UNKNOWN as public), this returns true + * ONLY for a CG we can positively confirm is public (open). Curated AND unknown + * both return false, so an in-flight chain-event race (policy not loaded yet) + * keeps the conservative ciphertext+allowlist gates and heals via member + * catchup — it can NEVER misclassify a curated CG as public and admit an + * unauthenticated plaintext envelope into curated storage. + */ + async isConfirmedPublicForHostMode(this: DKGAgent, contextGraphId: string): Promise { + const sub = this.subscribedContextGraphs.get(contextGraphId); + // Any curated marker is disqualifying. + if (sub?.onChainHash) return false; + if (sub?.onChainId) { + const cached = this.onChainAccessPolicyCache.get(sub.onChainId); + if (cached === 1) return false; + if (cached === 0) return true; // confirmed public on-chain + } + // Local `_meta` explicit accessPolicy is the authoritative tri-state: + // 'public' → yes, 'private' → no, null (unknown) → no (safe default). + try { + return (await this.getExplicitAccessPolicy(contextGraphId)) === 'public'; + } catch { + return false; + } + } + /** * Register the host-mode gossip handler for `contextGraphId` and * track its reference so {@link unwireSwmHostModeHandler} can @@ -1009,7 +1036,13 @@ export class SwmHostModeMethods extends DKGAgentBase { isCiphertext = skm.type === SWM_SENDER_KEY_MESSAGE_TYPE; } catch { /* fall through */ } } - if (!isCiphertext) return; + // GH #1124 — a curated CG MUST carry ciphertext, so a non-ciphertext + // envelope there is garbage → drop early (unchanged). A CONFIRMED-public + // (open) CG legitimately gossips PLAINTEXT SWM, so let it through to the + // authority + storage path below. Unknown CGs stay on the drop path (safe; + // member catchup heals once the policy resolves) — see + // isConfirmedPublicForHostMode for why UNKNOWN is treated as not-public. + if (!isCiphertext && !(await this.isConfirmedPublicForHostMode(storageCgId))) return; // Authority check: verify the envelope signature against the // curated CG's agent allowlist. Without this, a topic-reachable @@ -1024,6 +1057,24 @@ export class SwmHostModeMethods extends DKGAgentBase { const handler = this.getOrCreateSharedMemoryHandler(); const verdict = await handler.verifyHostModeEnvelopeAuthority(data, storageCgId, fromPeerId); if (!verdict.accepted) { + // GH #1124 — a CONFIRMED-public (open) CG has no curated agent allowlist, + // so the authority check returns 'no agent allowlist'. Accept it for such + // CGs: the envelope is already confirmed SIGNED (verifyHostModeEnvelopeAuthority + // rejects unsigned envelopes BEFORE the allowlist check), and public-CG + // host-mode storage is bounded by the per-CG byte cap + registration + // economics (the same safety net the pre-reg fail-open below relies on). + // Scoped to isConfirmedPublicForHostMode so a curated CG mid chain-event + // race (also 'no agent allowlist') is NEVER admitted — it stays a drop and + // heals via member catchup. Every OTHER rejection reason (decode failure, + // unsigned, sig mismatch, peer-not-in-allowlist) still drops for ALL CGs. + const noAllowlist = verdict.reason === 'no agent allowlist on context graph'; + if (noAllowlist && (await this.isConfirmedPublicForHostMode(storageCgId))) { + this.log.debug( + ctx, + `Host-mode accepting public-CG plaintext SWM cg=${storageCgId} from=${fromPeerId} (#1124; open CG has no curated allowlist, envelope is signed, per-CG byte cap remains the safety net)`, + ); + // fall through to the rate-limit + store path below + } else { // "no agent allowlist" is the expected outcome during the brief // chain-event race window (cores see the beacon, auto-engage // host-mode, then receive ciphertext BEFORE the @@ -1048,6 +1099,7 @@ export class SwmHostModeMethods extends DKGAgentBase { ); } return; + } } // OT-RFC-38 / LU-6 Phase B — pre-registration ciphertext rate- diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts new file mode 100644 index 000000000..fddb793fe --- /dev/null +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -0,0 +1,98 @@ +/** + * GH #1124 — public context graphs must be able to publish to Verifiable Memory. + * + * Host-mode cores dropped a PUBLIC CG's plaintext SWM share at two gates in + * `ingestSwmHostModeEnvelope` (the `isCiphertext` sniff + the curated-agent + * authority check), so a public CG's storage-ACK quorum was unreachable on a + * host-mode sharded topology. The fix opens BOTH gates — but ONLY for a CG that + * can be positively confirmed public via `isConfirmedPublicForHostMode`. + * + * The SECURITY-CRITICAL property is that helper's bias: a curated CG (including + * one whose on-chain policy hasn't loaded yet — the chain-event race) must NEVER + * be misclassified as public, because that would admit an unauthenticated + * plaintext envelope into curated storage. This pins: confirmed-public → true; + * curated marker → false; UNKNOWN → false (safe default, heals via catchup). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { DKGAgent } from '../../src/index.js'; + +interface Internals { + isConfirmedPublicForHostMode(cgId: string): Promise; + subscribedContextGraphs: Map; + onChainAccessPolicyCache: Map; + getExplicitAccessPolicy(cgId: string, opts?: unknown): Promise<'public' | 'private' | null>; +} + +describe('GH #1124 — isConfirmedPublicForHostMode safety bias (curated/unknown are NEVER public)', () => { + const tempDirs: string[] = []; + const agents: DKGAgent[] = []; + afterEach(async () => { + await Promise.all(agents.splice(0).map((a) => a.stop().catch(() => {}).then(() => a.store.close().catch(() => {})))); + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); + }); + + async function makeCore(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'dkg-1124-')); + tempDirs.push(dataDir); + const core = await DKGAgent.create({ name: 'Pub1124Core', listenHost: '127.0.0.1', dataDir, nodeRole: 'core' }); + agents.push(core); + return core; + } + + it('curated via onChainHash → NOT public (even if _meta would say public)', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-curated-hash'; + g.subscribedContextGraphs.set(cg, { subscribed: true, onChainHash: ethers.keccak256(ethers.toUtf8Bytes(cg)) }); + g.getExplicitAccessPolicy = async () => 'public'; // must NOT override the curated marker + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + }); + + it('curated via on-chain accessPolicy cache === 1 → NOT public', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-curated-cache'; + g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '4242' }); + g.onChainAccessPolicyCache.set('4242', 1); + g.getExplicitAccessPolicy = async () => 'public'; + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + }); + + it('confirmed public via on-chain accessPolicy cache === 0 → public', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-public-cache'; + g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '5151' }); + g.onChainAccessPolicyCache.set('5151', 0); + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true); + }); + + it('confirmed public via explicit _meta accessPolicy "public" → public', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-public-meta'; + g.getExplicitAccessPolicy = async () => 'public'; + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true); + }); + + it('explicit _meta accessPolicy "private" → NOT public', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-private-meta'; + g.getExplicitAccessPolicy = async () => 'private'; + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + }); + + it('UNKNOWN policy (null — chain-event race) → NOT public (safe default; the misclassification guard)', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-unknown'; + g.getExplicitAccessPolicy = async () => null; // policy not loaded yet + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + }); + + it('policy lookup THROWS → NOT public (fail-safe)', async () => { + const g = (await makeCore()) as unknown as Internals; + const cg = 'cg-throws'; + g.getExplicitAccessPolicy = async () => { throw new Error('chain unavailable'); }; + expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + }); +}); From 133bf009a44bd6a35a0f95c143a9ced150019dfe Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 12:05:31 +0200 Subject: [PATCH 03/12] =?UTF-8?q?fix(agent/swm):=20address=20otReviewAgent?= =?UTF-8?q?=20#1239=20findings=20(Kye-4=20=F0=9F=94=B4=20+=20Kye=5FC/Kye?= =?UTF-8?q?=5FD=20=F0=9F=9F=A1)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Kye-4 — isConfirmedPublicForHostMode resolved the access policy via a direct cleartext `subscribedContextGraphs` lookup, which MISSES for a host-only core whose subscription is keyed by the wire HASH (the exact #1124 sharded topology); the public envelope would still be dropped. Now delegates to the shared `getContextGraphOnChainPolicy` resolver (cache + local _meta + chain RPC, key-independent). Only accessPolicy===0 is public; curated(1)/unknown(undefined) → false (safe; heals via catchup). 🟡 Kye_C — the public-CG exception keyed off the free-form `verdict.reason` string 'no agent allowlist on context graph', coupling a log message to a behavioral branch. verifyHostModeEnvelopeAuthority now returns a structured `reasonCode` (HostModeRejectionCode enum); the ingest path keys off `reasonCode === 'NO_AGENT_ALLOWLIST'`. Also hardened the public path to verify the envelope signature self-consistency (recovers to the claimed signer) — a public CG short-circuits the authority check BEFORE the sig verify, so a forged/garbage signature is now rejected (unsigned was already dropped). 🟡 Kye_D — added an ingest-level test (host-mode-public-ingest-1124.test.ts) that drives a real SIGNED plaintext WorkspacePublishRequest through ingestSwmHostModeEnvelope: confirmed-public → STORED; curated/unknown → DROPPED; tampered signature → DROPPED. Classifier tests now pin the getContextGraphOnChainPolicy contract directly. Verified: 8/8 in the #1124 suite; agent host-mode/gossip/lu11 regression (42) + publisher workspace-handler authority (19) green. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-swm-host.ts | 52 ++++-- .../swm/host-mode-public-ingest-1124.test.ts | 159 ++++++++++++------ packages/publisher/src/workspace-handler.ts | 33 +++- 3 files changed, 174 insertions(+), 70 deletions(-) diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index de1a1b42d..209d3655f 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -688,18 +688,23 @@ export class SwmHostModeMethods extends DKGAgentBase { * unauthenticated plaintext envelope into curated storage. */ async isConfirmedPublicForHostMode(this: DKGAgent, contextGraphId: string): Promise { - const sub = this.subscribedContextGraphs.get(contextGraphId); - // Any curated marker is disqualifying. - if (sub?.onChainHash) return false; - if (sub?.onChainId) { - const cached = this.onChainAccessPolicyCache.get(sub.onChainId); - if (cached === 1) return false; - if (cached === 0) return true; // confirmed public on-chain - } - // Local `_meta` explicit accessPolicy is the authoritative tri-state: - // 'public' → yes, 'private' → no, null (unknown) → no (safe default). + // Resolve via the SHARED on-chain policy resolver rather than a direct + // cleartext `subscribedContextGraphs` lookup. `getContextGraphOnChainPolicy` + // re-keys cleartext↔on-chain-id (via subscribedContextGraphs OR + // getContextGraphOnChainId), consults the accessPolicy cache + local `_meta`, + // AND falls back to a direct chain RPC — so it resolves the policy even for a + // host-only core whose subscription is keyed by the wire HASH and that has no + // local `_meta` (the exact #1124 sharded topology). A cleartext-only + // subscribedContextGraphs probe would miss that entry and wrongly drop the + // public envelope. + // + // accessPolicy === 0 is the ONLY confirmed-public answer. Curated (1) and + // unknown (undefined) both → false — the safe bias: keep the ciphertext + + // allowlist gates so a curated CG mid chain-event race is never misclassified + // as public; it heals via member catchup once the policy resolves. try { - return (await this.getExplicitAccessPolicy(contextGraphId)) === 'public'; + const { accessPolicy } = await this.getContextGraphOnChainPolicy(contextGraphId); + return accessPolicy === 0; } catch { return false; } @@ -1067,11 +1072,27 @@ export class SwmHostModeMethods extends DKGAgentBase { // race (also 'no agent allowlist') is NEVER admitted — it stays a drop and // heals via member catchup. Every OTHER rejection reason (decode failure, // unsigned, sig mismatch, peer-not-in-allowlist) still drops for ALL CGs. - const noAllowlist = verdict.reason === 'no agent allowlist on context graph'; - if (noAllowlist && (await this.isConfirmedPublicForHostMode(storageCgId))) { + const noAllowlist = verdict.reasonCode === 'NO_AGENT_ALLOWLIST'; + // A public CG has no allowlist, so verifyHostModeEnvelopeAuthority returns + // NO_AGENT_ALLOWLIST and short-circuits BEFORE its signature check. So + // verify self-consistency here: the signature must recover to the claimed + // signer. This rejects a forged/garbage signature on the public path + // (the unsigned case — null envelope — is already dropped upstream as + // UNSIGNED, a different reasonCode that never reaches this branch). + let publicSigSelfConsistent = false; + if (noAllowlist && envelope.agentAddress && envelope.signature.length > 0) { + try { + const recovered = ethers.verifyMessage( + computeGossipSigningPayload(envelope.type, envelope.contextGraphId, envelope.timestamp, envelope.payload), + ethers.hexlify(envelope.signature), + ); + publicSigSelfConsistent = recovered.toLowerCase() === envelope.agentAddress.toLowerCase(); + } catch { publicSigSelfConsistent = false; } + } + if (noAllowlist && publicSigSelfConsistent && (await this.isConfirmedPublicForHostMode(storageCgId))) { this.log.debug( ctx, - `Host-mode accepting public-CG plaintext SWM cg=${storageCgId} from=${fromPeerId} (#1124; open CG has no curated allowlist, envelope is signed, per-CG byte cap remains the safety net)`, + `Host-mode accepting public-CG plaintext SWM cg=${storageCgId} from=${fromPeerId} (#1124; open CG has no curated allowlist; signature self-consistent; per-CG byte cap remains the safety net)`, ); // fall through to the rate-limit + store path below } else { @@ -1086,7 +1107,7 @@ export class SwmHostModeMethods extends DKGAgentBase { // state operation. Other rejection reasons (sig mismatch, peer // not in allowlist, decode failure) remain WARN — those are // real authority failures that operators need to see. - const isTransientRace = verdict.reason === 'no agent allowlist on context graph'; + const isTransientRace = verdict.reasonCode === 'NO_AGENT_ALLOWLIST'; if (isTransientRace) { this.log.debug( ctx, @@ -1164,7 +1185,6 @@ export class SwmHostModeMethods extends DKGAgentBase { } } } - const seqno = await this.swmHostModeStore.append(storageCgId, data); this.log.debug( ctx, diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index fddb793fe..7e094a8d1 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -10,24 +10,36 @@ * The SECURITY-CRITICAL property is that helper's bias: a curated CG (including * one whose on-chain policy hasn't loaded yet — the chain-event race) must NEVER * be misclassified as public, because that would admit an unauthenticated - * plaintext envelope into curated storage. This pins: confirmed-public → true; - * curated marker → false; UNKNOWN → false (safe default, heals via catchup). + * plaintext envelope into curated storage. `isConfirmedPublicForHostMode` + * delegates to the shared `getContextGraphOnChainPolicy` resolver (cache + _meta + * + chain RPC, key-independent) and treats ONLY `accessPolicy === 0` as public; + * curated (1) and unknown (undefined/throw) both → false. */ import { afterEach, describe, expect, it } from 'vitest'; import { ethers } from 'ethers'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { DKGAgent } from '../../src/index.js'; +import { encodeWorkspacePublishRequest } from '@origintrail-official/dkg-core'; +import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../../src/index.js'; +import { SwmHostModeStore } from '../../src/swm/host-mode-store.js'; -interface Internals { +interface ClassifierInternals { isConfirmedPublicForHostMode(cgId: string): Promise; - subscribedContextGraphs: Map; - onChainAccessPolicyCache: Map; - getExplicitAccessPolicy(cgId: string, opts?: unknown): Promise<'public' | 'private' | null>; + getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>; } -describe('GH #1124 — isConfirmedPublicForHostMode safety bias (curated/unknown are NEVER public)', () => { +interface IngestInternals { + isConfirmedPublicForHostMode(cgId: string): Promise; + encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise; + ingestSwmHostModeEnvelope(contextGraphId: string, data: Uint8Array, fromPeerId: string): Promise; + swmHostModeStore?: SwmHostModeStore; + localAgents: Map; + defaultAgentAddress?: string; + getSwmHostModeStats(): Promise<{ perCg?: Record } | undefined>; +} + +describe('GH #1124 — isConfirmedPublicForHostMode safety bias (only accessPolicy===0 is public)', () => { const tempDirs: string[] = []; const agents: DKGAgent[] = []; afterEach(async () => { @@ -43,56 +55,109 @@ describe('GH #1124 — isConfirmedPublicForHostMode safety bias (curated/unknown return core; } - it('curated via onChainHash → NOT public (even if _meta would say public)', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-curated-hash'; - g.subscribedContextGraphs.set(cg, { subscribed: true, onChainHash: ethers.keccak256(ethers.toUtf8Bytes(cg)) }); - g.getExplicitAccessPolicy = async () => 'public'; // must NOT override the curated marker - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + it('on-chain accessPolicy === 0 → public', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(true); }); - it('curated via on-chain accessPolicy cache === 1 → NOT public', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-curated-cache'; - g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '4242' }); - g.onChainAccessPolicyCache.set('4242', 1); - g.getExplicitAccessPolicy = async () => 'public'; - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + it('on-chain accessPolicy === 1 (curated) → NOT public', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1 }); + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + }); + + it('UNKNOWN policy (accessPolicy undefined — chain-event race) → NOT public (the misclassification guard)', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => ({}); // unresolved + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + }); + + it('policy resolver THROWS → NOT public (fail-safe)', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => { throw new Error('chain unavailable'); }; + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + }); +}); + +describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintext gossip end-to-end)', () => { + const tempDirs: string[] = []; + const agents: DKGAgent[] = []; + afterEach(async () => { + await Promise.all(agents.splice(0).map((a) => a.stop().catch(() => {}).then(() => a.store.close().catch(() => {})))); + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); }); - it('confirmed public via on-chain accessPolicy cache === 0 → public', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-public-cache'; - g.subscribedContextGraphs.set(cg, { subscribed: true, onChainId: '5151' }); - g.onChainAccessPolicyCache.set('5151', 0); - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true); + async function makeHostCore(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'dkg-1124-ingest-')); + tempDirs.push(dataDir); + const core = await DKGAgent.create({ name: 'Ingest1124Host', listenHost: '127.0.0.1', dataDir, nodeRole: 'core', swmHostMode: { enabled: true } }); + agents.push(core); + const store = new SwmHostModeStore({ dataDir: join(dataDir, 'swm-host'), ...SwmHostModeStore.defaultLimits() }); + await store.init(); + const g = core as unknown as IngestInternals; + g.swmHostModeStore = store; + // Register a local signing agent so encodeWorkspaceGossipMessage produces a + // real SIGNED gossip envelope (otherwise it returns the raw, undecodable payload). + const signer = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'signer'); + g.localAgents.set(signer.agentAddress, signer); + g.defaultAgentAddress = signer.agentAddress; + return core; + } + + const PEER = '12D3KooWHostModePublisherPeerForIngestTest'; + // A valid PLAINTEXT WorkspacePublishRequest (public SWM share) — not ciphertext, + // and decodable by the host's verifyHostModeEnvelopeAuthority path. + const plaintextRequest = (cg: string): Uint8Array => encodeWorkspacePublishRequest({ + contextGraphId: cg, + nquads: new TextEncoder().encode(' "Public1124" .'), + manifest: [{ rootEntity: 'urn:p01124:s' }], + publisherPeerId: PEER, + shareOperationId: `op-1124-${cg}`, + timestampMs: 1_700_000_000_000, }); - it('confirmed public via explicit _meta accessPolicy "public" → public', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-public-meta'; - g.getExplicitAccessPolicy = async () => 'public'; - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(true); + async function entriesFor(g: IngestInternals, cg: string): Promise { + const stats = await g.getSwmHostModeStats(); + return stats?.perCg?.[cg]?.entries ?? 0; + } + + it('CONFIRMED-PUBLIC: a signed plaintext SWM envelope is STORED (was dropped pre-#1124)', async () => { + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-public'; + g.isConfirmedPublicForHostMode = async () => true; // positively public + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + expect(await entriesFor(g, cg)).toBe(1); }); - it('explicit _meta accessPolicy "private" → NOT public', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-private-meta'; - g.getExplicitAccessPolicy = async () => 'private'; - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + it('CURATED: a plaintext envelope is DROPPED (Gate 1 — curated must be ciphertext)', async () => { + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-curated'; + g.isConfirmedPublicForHostMode = async () => false; // curated/not-public + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + expect(await entriesFor(g, cg)).toBe(0); }); - it('UNKNOWN policy (null — chain-event race) → NOT public (safe default; the misclassification guard)', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-unknown'; - g.getExplicitAccessPolicy = async () => null; // policy not loaded yet - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + it('UNKNOWN policy: a plaintext envelope is DROPPED (safe default — heals via catchup)', async () => { + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-unknown'; + g.isConfirmedPublicForHostMode = async () => false; // unknown resolves to not-public + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + expect(await entriesFor(g, cg)).toBe(0); }); - it('policy lookup THROWS → NOT public (fail-safe)', async () => { - const g = (await makeCore()) as unknown as Internals; - const cg = 'cg-throws'; - g.getExplicitAccessPolicy = async () => { throw new Error('chain unavailable'); }; - expect(await g.isConfirmedPublicForHostMode(cg)).toBe(false); + it('PUBLIC but TAMPERED signature: DROPPED (public path verifies signature self-consistency)', async () => { + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-public-forged'; + g.isConfirmedPublicForHostMode = async () => true; // public, but… + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + // Corrupt the trailing signature bytes so it no longer recovers to the signer. + const tampered = Uint8Array.from(env); + for (let i = 1; i <= 8 && i <= tampered.length; i++) tampered[tampered.length - i] ^= 0xff; + await g.ingestSwmHostModeEnvelope(cg, tampered, PEER); + expect(await entriesFor(g, cg)).toBe(0); }); }); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index ccf100508..bd966e38b 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -103,6 +103,23 @@ export interface ContextGraphMetaOracleRecord { * MUST fall back to NOT emitting an ack (silent best-effort, same * as pre-PR-D gossip behaviour). */ +/** + * Structured rejection code for {@link SharedMemoryHandler.verifyHostModeEnvelopeAuthority}. + * Callers (e.g. the host-mode ingest path's #1124 public-CG exception) key off + * this stable code rather than the free-form `reason` text, so a wording change + * to a log message can never silently flip a behavioral branch. + */ +export type HostModeRejectionCode = + | 'DECODE_FAILED' + | 'UNSIGNED' + | 'NO_AGENT_ALLOWLIST' + | 'PEER_NOT_IN_ALLOWLIST' + | 'SIG_VERIFY_FAILED'; + +export type HostModeEnvelopeAuthorityVerdict = + | { accepted: true } + | { accepted: false; reasonCode: HostModeRejectionCode; reason: string }; + export type SharedMemoryApplyOutcome = | { applied: true; @@ -1310,28 +1327,30 @@ export class SharedMemoryHandler { rawBytes: Uint8Array, contextGraphId: string, fromPeerId: string, - ): Promise<{ accepted: true } | { accepted: false; reason: string }> { + ): Promise { const ctx = createOperationContext('share'); let decoded: WorkspaceGossipDecodeResult; try { decoded = this.decodeWorkspaceGossipMessage(rawBytes); } catch (err) { const reason = err instanceof Error ? err.message : String(err); - return { accepted: false, reason: `decode failed: ${reason}` }; + return { accepted: false, reasonCode: 'DECODE_FAILED', reason: `decode failed: ${reason}` }; } const { envelope, signedPayload } = decoded; if (!envelope) { - return { accepted: false, reason: 'unsigned envelope (host mode requires agent-signed gossip)' }; + return { accepted: false, reasonCode: 'UNSIGNED', reason: 'unsigned envelope (host mode requires agent-signed gossip)' }; } const agentGateAddresses = await this.getContextGraphAgentGateAddresses(contextGraphId); const allowedPeers = await this.getContextGraphAllowedPeers(contextGraphId); if (agentGateAddresses === null) { // No agent gate → not curated → host mode shouldn't be - // active for this CG. Drop defensively. - return { accepted: false, reason: 'no agent allowlist on context graph' }; + // active for this CG. Drop defensively. (The host-mode ingest caller keys + // its public-CG exception off `reasonCode === 'NO_AGENT_ALLOWLIST'`, NOT + // the free-form `reason` text — keep the code stable.) + return { accepted: false, reasonCode: 'NO_AGENT_ALLOWLIST', reason: 'no agent allowlist on context graph' }; } if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) { - return { accepted: false, reason: `peer ${fromPeerId} not in peer allowlist` }; + return { accepted: false, reasonCode: 'PEER_NOT_IN_ALLOWLIST', reason: `peer ${fromPeerId} not in peer allowlist` }; } const verified = await this.verifyAgentEnvelope( envelope, @@ -1342,7 +1361,7 @@ export class SharedMemoryHandler { { requireLocalMembership: false }, ); if (!verified) { - return { accepted: false, reason: 'agent envelope verification failed (see preceding WARN log)' }; + return { accepted: false, reasonCode: 'SIG_VERIFY_FAILED', reason: 'agent envelope verification failed (see preceding WARN log)' }; } return { accepted: true }; } From f0c8df8c2bf1cafae9ece1ed2dd077e6472b3189 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 12:27:59 +0200 Subject: [PATCH 04/12] fix(agent/swm): route public-CG host-mode verification through the shared verifier (#1239 round-2) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses otReviewAgent round-2 on the #1124 public-CG path: 🔴 KzQNo — the public exception used a bare ethers.verifyMessage that skipped the shared verifier's timestamp-freshness window, so a previously-signed public envelope could be replayed indefinitely and re-appended (evicting newer entries from the bounded host-mode store). Moved the public acceptance INTO SharedMemoryHandler.verifyHostModeEnvelopeAuthority behind a new `allowSelfSignedForPublicCg` option: it now runs the SAME verifyAgentEnvelope as curated traffic (signature + 5-min freshness), with the claimed signer as its own one-entry allowlist (self-consistency). Only the allowlist decision diverges. 🔴 KzQNk — cross-CG injection: the public path stored an envelope without checking the inner WorkspacePublishRequest.contextGraphId, so a valid envelope for public CG-A carrying a payload for CG-B was stored under A and could be applied to B by catchup. verifyHostModeEnvelopeAuthority now rejects (CG_MISMATCH) when the inner request's contextGraphId differs from the envelope CG. 🟡 KzQNt — the positive ingest test stubbed isConfirmedPublicForHostMode, bypassing the resolver the fix depends on. Tests now stub getContextGraphOnChainPolicy (the real dependency) so the actual classifier + both gates run, and add a cross-CG case asserting CG_MISMATCH drops. The agent ingest path is simplified accordingly (compute confirmedPublic once; pass the flag; no bespoke crypto in the agent). Verified: #1124 suite 9/9; agent host-mode/gossip/lu11 (42) + publisher workspace-handler authority (19) green. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-swm-host.ts | 87 +++++-------------- .../swm/host-mode-public-ingest-1124.test.ts | 32 +++++-- packages/publisher/src/workspace-handler.ts | 52 +++++++++-- 3 files changed, 92 insertions(+), 79 deletions(-) diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index 209d3655f..df25be7db 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -1042,71 +1042,33 @@ export class SwmHostModeMethods extends DKGAgentBase { } catch { /* fall through */ } } // GH #1124 — a curated CG MUST carry ciphertext, so a non-ciphertext - // envelope there is garbage → drop early (unchanged). A CONFIRMED-public - // (open) CG legitimately gossips PLAINTEXT SWM, so let it through to the - // authority + storage path below. Unknown CGs stay on the drop path (safe; - // member catchup heals once the policy resolves) — see - // isConfirmedPublicForHostMode for why UNKNOWN is treated as not-public. - if (!isCiphertext && !(await this.isConfirmedPublicForHostMode(storageCgId))) return; - - // Authority check: verify the envelope signature against the - // curated CG's agent allowlist. Without this, a topic-reachable - // peer can fill per-CG storage with valid-looking ciphertext - // and evict legitimate history. + // envelope there is garbage → drop early. A CONFIRMED-public (open) CG + // legitimately gossips PLAINTEXT SWM. Resolve the public flag ONCE + // (key-independent — see isConfirmedPublicForHostMode) and reuse it for both + // the plaintext gate and the authority check. UNKNOWN CGs stay on the drop + // path (safe; member catchup heals once the policy resolves). + const confirmedPublic = await this.isConfirmedPublicForHostMode(storageCgId); + if (!isCiphertext && !confirmedPublic) return; + + // Authority check. Curated traffic verifies the envelope signature against + // the CG's agent allowlist. A confirmed-public CG has no allowlist, so pass + // `allowSelfSignedForPublicCg`: the SHARED verifier then validates signature + // + timestamp-freshness (the replay/eviction guard) AND binds the inner + // request to THIS CG — same envelope validation as curated, only the + // allowlist decision diverges (see SharedMemoryHandler.verifyHostModeEnvelopeAuthority). // - // Use `storageCgId` (cleartext from the envelope) so the - // member-side meta-graph + chain-fallback resolvers in - // `verifyHostModeEnvelopeAuthority` work on the canonical id - // shape. The hash subscription key is internal bookkeeping; - // never crosses an external authorization boundary. + // Use `storageCgId` (cleartext from the envelope) so the meta-graph + + // chain-fallback resolvers work on the canonical id shape. const handler = this.getOrCreateSharedMemoryHandler(); - const verdict = await handler.verifyHostModeEnvelopeAuthority(data, storageCgId, fromPeerId); + const verdict = await handler.verifyHostModeEnvelopeAuthority( + data, storageCgId, fromPeerId, { allowSelfSignedForPublicCg: confirmedPublic }, + ); if (!verdict.accepted) { - // GH #1124 — a CONFIRMED-public (open) CG has no curated agent allowlist, - // so the authority check returns 'no agent allowlist'. Accept it for such - // CGs: the envelope is already confirmed SIGNED (verifyHostModeEnvelopeAuthority - // rejects unsigned envelopes BEFORE the allowlist check), and public-CG - // host-mode storage is bounded by the per-CG byte cap + registration - // economics (the same safety net the pre-reg fail-open below relies on). - // Scoped to isConfirmedPublicForHostMode so a curated CG mid chain-event - // race (also 'no agent allowlist') is NEVER admitted — it stays a drop and - // heals via member catchup. Every OTHER rejection reason (decode failure, - // unsigned, sig mismatch, peer-not-in-allowlist) still drops for ALL CGs. - const noAllowlist = verdict.reasonCode === 'NO_AGENT_ALLOWLIST'; - // A public CG has no allowlist, so verifyHostModeEnvelopeAuthority returns - // NO_AGENT_ALLOWLIST and short-circuits BEFORE its signature check. So - // verify self-consistency here: the signature must recover to the claimed - // signer. This rejects a forged/garbage signature on the public path - // (the unsigned case — null envelope — is already dropped upstream as - // UNSIGNED, a different reasonCode that never reaches this branch). - let publicSigSelfConsistent = false; - if (noAllowlist && envelope.agentAddress && envelope.signature.length > 0) { - try { - const recovered = ethers.verifyMessage( - computeGossipSigningPayload(envelope.type, envelope.contextGraphId, envelope.timestamp, envelope.payload), - ethers.hexlify(envelope.signature), - ); - publicSigSelfConsistent = recovered.toLowerCase() === envelope.agentAddress.toLowerCase(); - } catch { publicSigSelfConsistent = false; } - } - if (noAllowlist && publicSigSelfConsistent && (await this.isConfirmedPublicForHostMode(storageCgId))) { - this.log.debug( - ctx, - `Host-mode accepting public-CG plaintext SWM cg=${storageCgId} from=${fromPeerId} (#1124; open CG has no curated allowlist; signature self-consistent; per-CG byte cap remains the safety net)`, - ); - // fall through to the rate-limit + store path below - } else { - // "no agent allowlist" is the expected outcome during the brief - // chain-event race window (cores see the beacon, auto-engage - // host-mode, then receive ciphertext BEFORE the - // `ContextGraphCreated` event lands AND before the curator - // beacon arrived). The beaconCuratorOracle fallback closes - // most of that window; the remaining race (envelope arrives - // before the beacon is received & verified) is recoverable - // via member catchup and should not spam WARN logs in steady- - // state operation. Other rejection reasons (sig mismatch, peer - // not in allowlist, decode failure) remain WARN — those are - // real authority failures that operators need to see. + // 'no agent allowlist' on a NON-public CG is the expected brief chain-event + // race (curated allowlist not loaded yet) — recoverable via member catchup, + // so log at debug. Every other rejection (decode / unsigned / signature-or- + // freshness / peer-not-allowed / CG-mismatch) is a real authority failure + // operators should see. const isTransientRace = verdict.reasonCode === 'NO_AGENT_ALLOWLIST'; if (isTransientRace) { this.log.debug( @@ -1120,7 +1082,6 @@ export class SwmHostModeMethods extends DKGAgentBase { ); } return; - } } // OT-RFC-38 / LU-6 Phase B — pre-registration ciphertext rate- diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index 7e094a8d1..05e3b14cd 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -30,7 +30,7 @@ interface ClassifierInternals { } interface IngestInternals { - isConfirmedPublicForHostMode(cgId: string): Promise; + getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>; encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise; ingestSwmHostModeEnvelope(contextGraphId: string, data: Uint8Array, fromPeerId: string): Promise; swmHostModeStore?: SwmHostModeStore; @@ -122,42 +122,56 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex return stats?.perCg?.[cg]?.entries ?? 0; } + // Drive the REAL classifier via the resolver it depends on (getContextGraphOnChainPolicy), + // NOT by stubbing isConfirmedPublicForHostMode — so these exercise the actual + // public-policy resolution + the two ingest gates end to end. it('CONFIRMED-PUBLIC: a signed plaintext SWM envelope is STORED (was dropped pre-#1124)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-public'; - g.isConfirmedPublicForHostMode = async () => true; // positively public + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); // resolves public const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); await g.ingestSwmHostModeEnvelope(cg, env, PEER); expect(await entriesFor(g, cg)).toBe(1); }); - it('CURATED: a plaintext envelope is DROPPED (Gate 1 — curated must be ciphertext)', async () => { + it('CURATED (accessPolicy 1): a plaintext envelope is DROPPED (Gate 1 — curated must be ciphertext)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-curated'; - g.isConfirmedPublicForHostMode = async () => false; // curated/not-public + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1 }); const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); await g.ingestSwmHostModeEnvelope(cg, env, PEER); expect(await entriesFor(g, cg)).toBe(0); }); - it('UNKNOWN policy: a plaintext envelope is DROPPED (safe default — heals via catchup)', async () => { + it('UNKNOWN policy (unresolved): a plaintext envelope is DROPPED (safe default — heals via catchup)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-unknown'; - g.isConfirmedPublicForHostMode = async () => false; // unknown resolves to not-public + g.getContextGraphOnChainPolicy = async () => ({}); // accessPolicy undefined const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); await g.ingestSwmHostModeEnvelope(cg, env, PEER); expect(await entriesFor(g, cg)).toBe(0); }); - it('PUBLIC but TAMPERED signature: DROPPED (public path verifies signature self-consistency)', async () => { + it('PUBLIC but TAMPERED signature: DROPPED (shared verifier rejects bad signature/freshness)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-public-forged'; - g.isConfirmedPublicForHostMode = async () => true; // public, but… + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); - // Corrupt the trailing signature bytes so it no longer recovers to the signer. const tampered = Uint8Array.from(env); for (let i = 1; i <= 8 && i <= tampered.length; i++) tampered[tampered.length - i] ^= 0xff; await g.ingestSwmHostModeEnvelope(cg, tampered, PEER); expect(await entriesFor(g, cg)).toBe(0); }); + + it('PUBLIC but inner request targets a DIFFERENT CG: DROPPED (no cross-CG injection)', async () => { + const g = (await makeHostCore()) as unknown as IngestInternals; + const cgEnvelope = 'cg-ingest-A'; + const cgInner = 'cg-ingest-B'; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); // both public + // Envelope is signed for CG-A but its inner WorkspacePublishRequest targets CG-B. + const env = await g.encodeWorkspaceGossipMessage(cgEnvelope, plaintextRequest(cgInner)); + await g.ingestSwmHostModeEnvelope(cgEnvelope, env, PEER); + expect(await entriesFor(g, cgEnvelope)).toBe(0); + expect(await entriesFor(g, cgInner)).toBe(0); + }); }); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index bd966e38b..92b975817 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -114,7 +114,8 @@ export type HostModeRejectionCode = | 'UNSIGNED' | 'NO_AGENT_ALLOWLIST' | 'PEER_NOT_IN_ALLOWLIST' - | 'SIG_VERIFY_FAILED'; + | 'SIG_VERIFY_FAILED' + | 'CG_MISMATCH'; export type HostModeEnvelopeAuthorityVerdict = | { accepted: true } @@ -1327,6 +1328,18 @@ export class SharedMemoryHandler { rawBytes: Uint8Array, contextGraphId: string, fromPeerId: string, + options?: { + /** + * GH #1124 — the caller (host-mode ingest) has positively confirmed this + * CG is PUBLIC (open, no curated agent allowlist). When set, a CG with no + * agent gate is accepted as long as the envelope passes the SAME shared + * verifier as curated traffic (signature + timestamp-freshness — NOT a + * bespoke check), with the claimed signer as its own one-entry allowlist + * (self-consistency), AND the inner request targets THIS CG. When unset, + * a no-allowlist CG is rejected defensively (curated mid-race / unknown). + */ + allowSelfSignedForPublicCg?: boolean; + }, ): Promise { const ctx = createOperationContext('share'); let decoded: WorkspaceGossipDecodeResult; @@ -1336,18 +1349,43 @@ export class SharedMemoryHandler { const reason = err instanceof Error ? err.message : String(err); return { accepted: false, reasonCode: 'DECODE_FAILED', reason: `decode failed: ${reason}` }; } - const { envelope, signedPayload } = decoded; + const { envelope, signedPayload, request } = decoded; if (!envelope) { return { accepted: false, reasonCode: 'UNSIGNED', reason: 'unsigned envelope (host mode requires agent-signed gossip)' }; } const agentGateAddresses = await this.getContextGraphAgentGateAddresses(contextGraphId); const allowedPeers = await this.getContextGraphAllowedPeers(contextGraphId); if (agentGateAddresses === null) { - // No agent gate → not curated → host mode shouldn't be - // active for this CG. Drop defensively. (The host-mode ingest caller keys - // its public-CG exception off `reasonCode === 'NO_AGENT_ALLOWLIST'`, NOT - // the free-form `reason` text — keep the code stable.) - return { accepted: false, reasonCode: 'NO_AGENT_ALLOWLIST', reason: 'no agent allowlist on context graph' }; + // GH #1124 — public (open) CGs have no curated agent allowlist. When the + // caller confirmed the CG is public, accept a self-signed envelope, but + // verify it through the SAME `verifyAgentEnvelope` the curated path uses — + // so the signature AND the 5-minute timestamp-freshness window (replay / + // store-eviction guard) are enforced identically; only the allowlist set + // diverges (the claimed signer is its own one-entry allowlist). + if (!options?.allowSelfSignedForPublicCg) { + // Not confirmed public → curated-mid-race or unknown. Drop defensively. + // (The host-mode ingest caller keys its transient-race log off + // `reasonCode === 'NO_AGENT_ALLOWLIST'` — keep the code stable.) + return { accepted: false, reasonCode: 'NO_AGENT_ALLOWLIST', reason: 'no agent allowlist on context graph' }; + } + if (!envelope.agentAddress || !ethers.isAddress(envelope.agentAddress)) { + return { accepted: false, reasonCode: 'SIG_VERIFY_FAILED', reason: 'public-CG envelope missing a valid signer address' }; + } + const selfConsistent = await this.verifyAgentEnvelope( + envelope, signedPayload, contextGraphId, [ethers.getAddress(envelope.agentAddress)], ctx, { requireLocalMembership: false }, + ); + if (!selfConsistent) { + return { accepted: false, reasonCode: 'SIG_VERIFY_FAILED', reason: 'public-CG envelope failed signature/freshness verification' }; + } + // Bind the inner request to THIS CG: the apply/catchup path derives the + // target from `request.contextGraphId`, so an envelope signed for CG-A + // carrying a payload for CG-B must NOT be stored under A (cross-CG + // injection on the open host-mode topic). The plaintext request is always + // decoded for a public envelope (`decoded.request`). + if (request && request.contextGraphId !== contextGraphId) { + return { accepted: false, reasonCode: 'CG_MISMATCH', reason: `inner request contextGraphId "${request.contextGraphId}" != envelope CG "${contextGraphId}"` }; + } + return { accepted: true }; } if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) { return { accepted: false, reasonCode: 'PEER_NOT_IN_ALLOWLIST', reason: `peer ${fromPeerId} not in peer allowlist` }; From 9678a2acb143002c59426861dac1930455822dcd Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 13:05:56 +0200 Subject: [PATCH 05/12] fix(agent/swm): gate self-signed public ingest on OPEN PUBLISH policy; skip keyless signers (#1239 round-3) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Kzii9 — isConfirmedPublicForHostMode gated the self-signed public host-mode ingest path on accessPolicy===0 (READ visibility) alone. But read visibility and write authority are separate: a public-READABLE CG can still have publishPolicy===0 (curated / PCA publishing). For such a CG (no resolved agent allowlist), the self-signed path would let ANY key store plaintext SWM on host-mode cores and bypass the on-chain publisher authorization. Now requires BOTH accessPolicy===0 AND publishPolicy===1 (open read AND open write); curated/unknown on either axis → false (getContextGraphOnChainPolicy already returns both, with a chain-RPC fallback). Verified live earlier on a CG created publishPolicy:1, so the #1124 behaviour is unchanged for genuinely-open CGs. 🟡 KzijE — getWorkspaceGossipSigningAgent now SKIPS a record with no valid agentAddress entirely, rather than letting it become the fallback signer (which would emit an envelope with a missing agentAddress that downstream rejects). Avoids the original #787 crash AND picks a usable signer. 🟡 KzijJ — the #306/#787 daemon test exits at the HTTP quad-shape boundary before the signer is selected, so it wouldn't catch a revert. Added gossip-signer-selection-787.test.ts: a keyless record placed ahead of a valid signer is skipped (no crash; valid signer chosen; null when only keyless records). Verified: #1124 suite 10/10 + #787 signer 3/3; agent host-mode/gossip/lu11 (33), cli #306/#787 daemon (4), publisher workspace-handler authority (12) green. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-crypto.ts | 14 ++-- packages/agent/src/dkg-agent-swm-host.ts | 40 +++++----- .../test/gossip-signer-selection-787.test.ts | 77 +++++++++++++++++++ .../swm/host-mode-public-ingest-1124.test.ts | 30 +++++--- 4 files changed, 125 insertions(+), 36 deletions(-) create mode 100644 packages/agent/test/gossip-signer-selection-787.test.ts diff --git a/packages/agent/src/dkg-agent-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index e65485630..9fdb89b5e 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -386,12 +386,16 @@ export class WorkspaceCryptoMethods extends DKGAgentBase { let fallback: (AgentKeyRecord & { privateKey: string }) | null = null; for (const record of this.localAgents.values()) { if (!record.privateKey) continue; + // GH #787 — a node-level key record can carry a privateKey but no (or an + // invalid) agentAddress (an operational identity, not an agent). Such a + // record is NOT a usable gossip signer: encodeWorkspaceGossipMessage emits + // `agentAddress` into the envelope and the downstream host-mode authority + // check rejects a missing/invalid one. Skip it entirely — that both avoids + // the original `toLowerCase()`-of-undefined crash (HTTP 500 on SWM write) + // AND prevents it becoming a fallback that emits an unverifiable envelope. + if (!record.agentAddress || !ethers.isAddress(record.agentAddress)) continue; const signingRecord = { ...record, privateKey: record.privateKey }; - // GH #787 — a node-level key record can carry a privateKey but no - // agentAddress (operational identity, not an agent). Guard the compare so - // it falls through to the fallback signer instead of throwing TypeError - // (`toLowerCase` of undefined → HTTP 500 on every SWM write via that token). - if (defaultAddress && record.agentAddress?.toLowerCase() === defaultAddress) { + if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) { return signingRecord; } fallback ??= signingRecord; diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index df25be7db..967f3aa24 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -679,32 +679,30 @@ export class SwmHostModeMethods extends DKGAgentBase { } /** - * GH #1124 — DEFINITIVE public-CG check for the host-mode ingest gates. Unlike - * `!isCuratedForHostMode` (which treats UNKNOWN as public), this returns true - * ONLY for a CG we can positively confirm is public (open). Curated AND unknown - * both return false, so an in-flight chain-event race (policy not loaded yet) - * keeps the conservative ciphertext+allowlist gates and heals via member - * catchup — it can NEVER misclassify a curated CG as public and admit an - * unauthenticated plaintext envelope into curated storage. + * GH #1124 — DEFINITIVE "fully-open CG" check gating the self-signed public + * host-mode ingest path. "Open" requires BOTH axes, because this codebase + * separates READ visibility from WRITE authority: + * - accessPolicy === 0 → publicly READABLE (SWM is plaintext), AND + * - publishPolicy === 1 → OPEN PUBLISH (anyone may write). + * A public-readable but curated-publish CG (accessPolicy 0, publishPolicy 0 / + * PCA) still restricts WHO may publish, so the self-signed path must NOT apply + * — otherwise any key could store plaintext SWM on host-mode cores and bypass + * the on-chain publisher authorization (otReviewAgent #1239-r3). Curated OR + * unknown on EITHER axis → false: the conservative ciphertext + allowlist gates + * stay in force and a chain-event race heals via member catchup, so a curated + * (or restricted-publish) CG is never misclassified as self-publishable. */ async isConfirmedPublicForHostMode(this: DKGAgent, contextGraphId: string): Promise { // Resolve via the SHARED on-chain policy resolver rather than a direct // cleartext `subscribedContextGraphs` lookup. `getContextGraphOnChainPolicy` - // re-keys cleartext↔on-chain-id (via subscribedContextGraphs OR - // getContextGraphOnChainId), consults the accessPolicy cache + local `_meta`, - // AND falls back to a direct chain RPC — so it resolves the policy even for a - // host-only core whose subscription is keyed by the wire HASH and that has no - // local `_meta` (the exact #1124 sharded topology). A cleartext-only - // subscribedContextGraphs probe would miss that entry and wrongly drop the - // public envelope. - // - // accessPolicy === 0 is the ONLY confirmed-public answer. Curated (1) and - // unknown (undefined) both → false — the safe bias: keep the ciphertext + - // allowlist gates so a curated CG mid chain-event race is never misclassified - // as public; it heals via member catchup once the policy resolves. + // re-keys cleartext↔on-chain-id, consults the cache + local `_meta`, AND + // falls back to a direct chain RPC — so it resolves BOTH policies even for a + // host-only core keyed by the wire HASH with no local `_meta` (the #1124 + // sharded topology). Both must positively resolve to their open value; any + // undefined (unknown) → false (safe). try { - const { accessPolicy } = await this.getContextGraphOnChainPolicy(contextGraphId); - return accessPolicy === 0; + const { accessPolicy, publishPolicy } = await this.getContextGraphOnChainPolicy(contextGraphId); + return accessPolicy === 0 && publishPolicy === 1; } catch { return false; } diff --git a/packages/agent/test/gossip-signer-selection-787.test.ts b/packages/agent/test/gossip-signer-selection-787.test.ts new file mode 100644 index 000000000..92a313442 --- /dev/null +++ b/packages/agent/test/gossip-signer-selection-787.test.ts @@ -0,0 +1,77 @@ +/** + * GH #787 (regression) — `getWorkspaceGossipSigningAgent` must skip a local key + * record that has a privateKey but NO valid `agentAddress` (a node-level + * operational identity, not an agent). Such a record can't be a usable gossip + * signer: `encodeWorkspaceGossipMessage` emits `agentAddress` into the envelope + * and the host-mode authority check rejects a missing one. + * + * The #306/#787 daemon test exercises only the HTTP quad-shape validation, which + * now short-circuits at the route boundary BEFORE the signer is selected — so it + * would NOT catch a revert of this guard. This test drives the signer selection + * directly: a keyless-agent record placed AHEAD of a valid signer must be + * skipped (no `toLowerCase()`-of-undefined crash, and not chosen as fallback). + */ +import { afterEach, describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../src/index.js'; + +interface Internals { + localAgents: Map; + defaultAgentAddress?: string; + getWorkspaceGossipSigningAgent(): (AgentKeyRecord & { privateKey: string }) | null; + encodeWorkspaceGossipMessage(cg: string, msg: Uint8Array): Promise; +} + +function keylessAgentRecord(label: string): AgentKeyRecord { + const rec = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, label); + // A node-level operational key: has a privateKey but no agent identity. + delete (rec as { agentAddress?: string }).agentAddress; + return rec; +} + +describe('GH #787 — gossip signer selection skips keyless-agent records', () => { + let agent: DKGAgent | null = null; + afterEach(async () => { if (agent) { await agent.stop().catch(() => {}); agent = null; } }); + + it('keyless record placed FIRST + default match present → returns the valid signer (no throw)', async () => { + agent = await DKGAgent.create({ name: 'Signer787A', chainAdapter: new MockChainAdapter() }); + const g = agent as unknown as Internals; + g.localAgents.clear(); + const keyless = keylessAgentRecord('node-op'); + const valid = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'agent'); + g.localAgents.set('node-op-key', keyless); // FIRST — pre-fix this crashed on `.toLowerCase()` of undefined + g.localAgents.set(valid.agentAddress, valid); + g.defaultAgentAddress = valid.agentAddress; + + const signer = g.getWorkspaceGossipSigningAgent(); + expect(signer).not.toBeNull(); + expect(signer!.agentAddress).toBe(valid.agentAddress); + // And signing actually works end to end (a real signed envelope, not a crash + // or the raw-payload passthrough that happens with no usable signer). + const env = await g.encodeWorkspaceGossipMessage('cg-787', new TextEncoder().encode('payload')); + expect(env.length).toBeGreaterThan(64); + }); + + it('keyless record FIRST + NO default match → falls back to the valid signer (skips the keyless one)', async () => { + agent = await DKGAgent.create({ name: 'Signer787B', chainAdapter: new MockChainAdapter() }); + const g = agent as unknown as Internals; + g.localAgents.clear(); + g.localAgents.set('node-op-key', keylessAgentRecord('node-op')); + const valid = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'agent'); + g.localAgents.set(valid.agentAddress, valid); + g.defaultAgentAddress = undefined; // no default → exercise fallback selection + + const signer = g.getWorkspaceGossipSigningAgent(); + expect(signer?.agentAddress).toBe(valid.agentAddress); + }); + + it('ONLY keyless-agent records → no usable signer (null, no throw)', async () => { + agent = await DKGAgent.create({ name: 'Signer787C', chainAdapter: new MockChainAdapter() }); + const g = agent as unknown as Internals; + g.localAgents.clear(); + g.localAgents.set('k1', keylessAgentRecord('k1')); + g.defaultAgentAddress = undefined; + expect(g.getWorkspaceGossipSigningAgent()).toBeNull(); + }); +}); diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index 05e3b14cd..e3f9fad12 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -55,21 +55,31 @@ describe('GH #1124 — isConfirmedPublicForHostMode safety bias (only accessPoli return core; } - it('on-chain accessPolicy === 0 → public', async () => { + it('open read + open publish (accessPolicy 0, publishPolicy 1) → public', async () => { const g = (await makeCore()) as unknown as ClassifierInternals; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); expect(await g.isConfirmedPublicForHostMode('cg')).toBe(true); }); - it('on-chain accessPolicy === 1 (curated) → NOT public', async () => { + it('public READ but curated PUBLISH (accessPolicy 0, publishPolicy 0) → NOT self-publishable', async () => { + // The #1239-r3 🔴: read visibility ≠ write authority. A publicly-readable CG + // can still restrict who may publish; the self-signed path must NOT apply. const g = (await makeCore()) as unknown as ClassifierInternals; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1 }); + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 0 }); expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); }); - it('UNKNOWN policy (accessPolicy undefined — chain-event race) → NOT public (the misclassification guard)', async () => { + it('curated read (accessPolicy 1) → NOT public, regardless of publishPolicy', async () => { const g = (await makeCore()) as unknown as ClassifierInternals; - g.getContextGraphOnChainPolicy = async () => ({}); // unresolved + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1, publishPolicy: 1 }); + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + }); + + it('UNKNOWN policy (either axis undefined — chain-event race) → NOT public (the misclassification guard)', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); // publishPolicy unresolved + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + g.getContextGraphOnChainPolicy = async () => ({}); // both unresolved expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); }); @@ -128,7 +138,7 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex it('CONFIRMED-PUBLIC: a signed plaintext SWM envelope is STORED (was dropped pre-#1124)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-public'; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); // resolves public + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); // resolves fully-open (public read + open publish) const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); await g.ingestSwmHostModeEnvelope(cg, env, PEER); expect(await entriesFor(g, cg)).toBe(1); @@ -137,7 +147,7 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex it('CURATED (accessPolicy 1): a plaintext envelope is DROPPED (Gate 1 — curated must be ciphertext)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-curated'; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1 }); + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1, publishPolicy: 0 }); const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); await g.ingestSwmHostModeEnvelope(cg, env, PEER); expect(await entriesFor(g, cg)).toBe(0); @@ -155,7 +165,7 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex it('PUBLIC but TAMPERED signature: DROPPED (shared verifier rejects bad signature/freshness)', async () => { const g = (await makeHostCore()) as unknown as IngestInternals; const cg = 'cg-ingest-public-forged'; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); const tampered = Uint8Array.from(env); for (let i = 1; i <= 8 && i <= tampered.length; i++) tampered[tampered.length - i] ^= 0xff; @@ -167,7 +177,7 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex const g = (await makeHostCore()) as unknown as IngestInternals; const cgEnvelope = 'cg-ingest-A'; const cgInner = 'cg-ingest-B'; - g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 }); // both public + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); // both fully-open (isolate the CG-binding check) // Envelope is signed for CG-A but its inner WorkspacePublishRequest targets CG-B. const env = await g.encodeWorkspaceGossipMessage(cgEnvelope, plaintextRequest(cgInner)); await g.ingestSwmHostModeEnvelope(cgEnvelope, env, PEER); From ece492d2f446c5e67fc5e31baabda7aa48c19a26 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 13:56:40 +0200 Subject: [PATCH 06/12] fix(publisher/swm): bind public-CG host-mode entries to a decoded request + sender peer (#1239 round-4) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 K0FKl — a public self-signed host-mode entry is later applied via host catchup with `trustedReplay`, which SKIPS the apply-time `publisherPeerId === fromPeerId` transport binding. The round-2/3 public path only checked the inner CG "when a request decoded", so: - a ciphertext/garbage payload (request undefined) fell through to accept, and - an honestly-signed envelope whose inner publisherPeerId named ANOTHER peer was stored, and catchup then applied the write under that spoofed publisher identity. verifyHostModeEnvelopeAuthority's public branch now, before accepting: 1. REQUIRES a decoded WorkspacePublishRequest (reject if none), 2. binds request.contextGraphId to the envelope CG (cross-CG injection guard), and 3. binds request.publisherPeerId to the actual sender fromPeerId (publisher-spoof guard) — mirroring the apply-time binding that trustedReplay skips. New reasonCode PUBLISHER_PEER_MISMATCH. Test: host-mode-public-ingest-1124.test.ts adds a publisher-spoof case (inner publisherPeerId != sender → dropped). #1124 suite 11/11; publisher workspace-handler authority/trusted-replay (19) + agent host-mode/lu11/signer (30) green. Co-Authored-By: Claude Opus 4.8 --- .../swm/host-mode-public-ingest-1124.test.ts | 15 ++++++++++ packages/publisher/src/workspace-handler.ts | 30 ++++++++++++++----- 2 files changed, 38 insertions(+), 7 deletions(-) diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index e3f9fad12..2740e7e56 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -184,4 +184,19 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex expect(await entriesFor(g, cgEnvelope)).toBe(0); expect(await entriesFor(g, cgInner)).toBe(0); }); + + it('PUBLIC but inner publisherPeerId names a DIFFERENT peer than the sender: DROPPED (no publisher spoof)', async () => { + // Host catchup later applies stored entries with trustedReplay (skipping the + // publisherPeerId↔sender binding), so it must be enforced at ingest: a peer + // relaying an honestly-signed envelope whose inner publisherPeerId names + // ANOTHER peer must NOT be stored (otherwise catchup applies it under the + // spoofed publisher/ownership identity). + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-spoof'; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); + // plaintextRequest(cg) sets publisherPeerId = PEER; deliver it from a DIFFERENT sender. + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + await g.ingestSwmHostModeEnvelope(cg, env, '12D3KooWSomeOtherRelayPeerNotThePublisher'); + expect(await entriesFor(g, cg)).toBe(0); + }); }); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index 92b975817..d54ee9f6e 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -115,7 +115,8 @@ export type HostModeRejectionCode = | 'NO_AGENT_ALLOWLIST' | 'PEER_NOT_IN_ALLOWLIST' | 'SIG_VERIFY_FAILED' - | 'CG_MISMATCH'; + | 'CG_MISMATCH' + | 'PUBLISHER_PEER_MISMATCH'; export type HostModeEnvelopeAuthorityVerdict = | { accepted: true } @@ -1377,14 +1378,29 @@ export class SharedMemoryHandler { if (!selfConsistent) { return { accepted: false, reasonCode: 'SIG_VERIFY_FAILED', reason: 'public-CG envelope failed signature/freshness verification' }; } - // Bind the inner request to THIS CG: the apply/catchup path derives the - // target from `request.contextGraphId`, so an envelope signed for CG-A - // carrying a payload for CG-B must NOT be stored under A (cross-CG - // injection on the open host-mode topic). The plaintext request is always - // decoded for a public envelope (`decoded.request`). - if (request && request.contextGraphId !== contextGraphId) { + // A public self-signed host-mode entry is later applied via host catchup + // with `trustedReplay` (which SKIPS the publisherPeerId↔sender transport + // binding at apply time — see the `!trustedReplay && publisherPeerId !== + // fromPeerId` guard below). So bind it HERE, before it is ever stored: + // 1. REQUIRE a decoded WorkspacePublishRequest — a ciphertext / garbage + // payload has no verifiable inner identity, so reject it (don't fall + // through to accept as the prior `request && …` check did). + // 2. Bind the inner request to THIS CG (the apply path derives the target + // from `request.contextGraphId`) — block cross-CG injection on the + // open host-mode topic. + // 3. Bind `request.publisherPeerId` to the actual sender. Without this a + // peer could relay an honestly-signed public envelope naming a + // DIFFERENT publisherPeerId and have catchup apply the write under that + // spoofed publisher/ownership identity (otReviewAgent #1239-r4). + if (!request) { + return { accepted: false, reasonCode: 'CG_MISMATCH', reason: 'public-CG envelope carries no decodable WorkspacePublishRequest' }; + } + if (request.contextGraphId !== contextGraphId) { return { accepted: false, reasonCode: 'CG_MISMATCH', reason: `inner request contextGraphId "${request.contextGraphId}" != envelope CG "${contextGraphId}"` }; } + if (request.publisherPeerId !== fromPeerId) { + return { accepted: false, reasonCode: 'PUBLISHER_PEER_MISMATCH', reason: `public-CG inner publisherPeerId "${request.publisherPeerId}" does not match sender "${fromPeerId}"` }; + } return { accepted: true }; } if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) { From bb19c568b6aeb5f5c04a75b23189f78e3e60a00a Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 14:57:08 +0200 Subject: [PATCH 07/12] fix(#1124): force-fresh publishPolicy for host-mode admission + demonstrate quorum reached (Branimir review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Addresses the two sufficiency/scope calls in Branimir's review of PR #1239. 1. Close the ≤60s stale-publishPolicy admission window. `publishPolicy` is mutable on-chain (PublishPolicyUpdated) and the resolver serves it from a ≤60s-TTL cache, but `isConfirmedPublicForHostMode` is the first security-positive consumer — a stale `publishPolicy=1` would admit a self-signed plaintext write for up to the TTL after an owner downgrades open→curated. Add `getContextGraphOnChainPolicy(cg, { forcePublishPolicyChainRead })` which treats the publishPolicy cache as always-stale and re-reads from chain (fail-closed on RPC error). The host-mode gate now passes the flag; other callers are unaffected (param is optional). accessPolicy stays un-TTL'd — it is immutable on-chain. 2. Demonstrate the end-state (quorum *reached*), not just the gate drop. `host-mode-quorum-bridge-1124.test.ts` wires the real ACKCollector to real StorageACKHandlers over real stores and reaches a public-CG quorum purely from non-member host-mode cores holding only the host-mode-ingested plaintext (valid EIP-191 ACKs); negative control shows the pre-fix empty-SWM state returns NO_DATA_IN_SWM (quorum-blocking). The load-bearing fact: StorageACKHandlerConfig has no membership input, so a non-member host's ACK is consensus-identical to a member's. Resolver- and gate-level tests pin the force-fresh read. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-cg-registry.ts | 19 +- packages/agent/src/dkg-agent-swm-host.ts | 12 +- .../test/dkg-agent-on-chain-policy.test.ts | 34 ++++ .../swm/host-mode-public-ingest-1124.test.ts | 17 +- .../test/host-mode-quorum-bridge-1124.test.ts | 179 ++++++++++++++++++ 5 files changed, 258 insertions(+), 3 deletions(-) create mode 100644 packages/publisher/test/host-mode-quorum-bridge-1124.test.ts diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 5409dedda..4e900c309 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -474,7 +474,21 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { * unavailable, contract not deployed, transient errors) are logged * and the field is left undefined. */ - async getContextGraphOnChainPolicy(this: DKGAgent, contextGraphId: string): Promise<{ + async getContextGraphOnChainPolicy(this: DKGAgent, contextGraphId: string, options?: { + /** + * Force a fresh chain RPC for `publishPolicy`, ignoring the (≤60s-TTL) + * cache. `publishPolicy` is mutable on-chain (`PublishPolicyUpdated`) and the + * agent has no event watcher, so the cache can be stale-PERMISSIVE for up to + * the TTL after an owner downgrades open→curated publish. Most callers (e.g. + * the import-artifact owner guard) tolerate that window, but a SECURITY- + * POSITIVE admission decision (host-mode self-signed plaintext ingest, see + * isConfirmedPublicForHostMode) must NOT — a stale `publishPolicy=1` there + * would admit a non-authorized write. With this set, the publishPolicy cache + * is treated as always-stale so the chain RPC re-verifies; an RPC + * failure/timeout leaves it undefined → the caller fails closed. + */ + forcePublishPolicyChainRead?: boolean; + }): Promise<{ accessPolicy?: number; publishPolicy?: number; }> { @@ -490,6 +504,9 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { // escalate privilege (gossip decrypt is gated by sender-key // issuance) and stale-restrictive only causes a transient deny. const isPublishPolicyCacheFresh = (key: string): boolean => { + // A security-positive caller can force a fresh chain re-verify (the cache + // may be stale-permissive for up to the TTL after an open→curated downgrade). + if (options?.forcePublishPolicyChainRead) return false; const fetchedAt = this.onChainPublishPolicyCacheUpdatedAt.get(key); if (fetchedAt === undefined) return false; return Date.now() - fetchedAt <= ON_CHAIN_PUBLISH_POLICY_CACHE_TTL_MS; diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index 967f3aa24..74d3eca34 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -701,7 +701,17 @@ export class SwmHostModeMethods extends DKGAgentBase { // sharded topology). Both must positively resolve to their open value; any // undefined (unknown) → false (safe). try { - const { accessPolicy, publishPolicy } = await this.getContextGraphOnChainPolicy(contextGraphId); + // `forcePublishPolicyChainRead`: publishPolicy is mutable on-chain and the + // cache is only ≤60s-TTL'd, so it could be stale-PERMISSIVE for up to the + // TTL after an owner downgrades open→curated publish. This is a + // security-positive gate (it admits a self-signed plaintext write that host + // catchup later applies under trustedReplay), so it must re-verify the + // publishPolicy on-chain rather than trust the cached value. An RPC + // failure/timeout leaves publishPolicy undefined → we fail CLOSED (drop; + // the share heals via retry/catchup once the policy re-resolves). + const { accessPolicy, publishPolicy } = await this.getContextGraphOnChainPolicy( + contextGraphId, { forcePublishPolicyChainRead: true }, + ); return accessPolicy === 0 && publishPolicy === 1; } catch { return false; diff --git a/packages/agent/test/dkg-agent-on-chain-policy.test.ts b/packages/agent/test/dkg-agent-on-chain-policy.test.ts index 8c6d35a9f..28afa38d3 100644 --- a/packages/agent/test/dkg-agent-on-chain-policy.test.ts +++ b/packages/agent/test/dkg-agent-on-chain-policy.test.ts @@ -454,6 +454,40 @@ describe('DKGAgent.getContextGraphOnChainPolicy', () => { expect(Date.now() - newTs).toBeLessThan(1_000); }); + // Branimir review on #1239 — the host-mode self-signed admission gate + // (`isConfirmedPublicForHostMode`) is the first SECURITY-POSITIVE consumer of + // the cached `publishPolicy`, so it must NOT tolerate the ≤60s stale-permissive + // window: a host with a fresh `publishPolicy=1` entry would otherwise admit a + // self-signed plaintext write for up to the TTL after an owner downgrades + // open→curated. `forcePublishPolicyChainRead` treats the cache as always-stale + // so the chain RPC re-verifies. Same state, two answers: cached `1` without the + // flag, fresh-from-chain `0` with it. + it('forcePublishPolicyChainRead bypasses a FRESH publishPolicy cache and re-verifies on-chain (Branimir #1239)', async () => { + const getContextGraphPublishPolicy = recorder(async () => ({ + publishPolicy: 0, // the owner just flipped open → curated + publishAuthority: '0x0000000000000000000000000000000000000000', + })); + const stub = makeStub({ + subscribedContextGraphs: new Map([['cg-force', { onChainId: '88' }]]), + // FRESH cache says "1" (open) — seeded just now, well within the TTL. + onChainPublishPolicyCache: new Map([['cg-force', 1]]), + onChainPublishPolicyCacheUpdatedAt: new Map([['cg-force', Date.now()]]), + onChainAccessPolicyCache: new Map([['cg-force', 0]]), + isContextGraphRegistered: recorder(async () => true), + chain: { getContextGraphPublishPolicy }, + }); + // Default (no flag): the fresh cache hit returns the stale-permissive "1". + expect(await callPolicy(stub, 'cg-force')).toEqual({ accessPolicy: 0, publishPolicy: 1 }); + expect(getContextGraphPublishPolicy.calls).toEqual([]); // no RPC — cache hit + // Forced: the cache is ignored, the chain RPC re-verifies → "0" (curated), + // so the admission gate sees the real (downgraded) policy and fails closed. + const forced = await (DKGAgent.prototype as any).getContextGraphOnChainPolicy.call( + stub, 'cg-force', { forcePublishPolicyChainRead: true }, + ); + expect(forced).toEqual({ accessPolicy: 0, publishPolicy: 0 }); + expect(getContextGraphPublishPolicy.calls.at(-1)).toEqual([88n]); + }); + // Round 3 — degenerate state: registered locally but the on-chain // id resolution fails (e.g. corrupted ontology graph). We can't // RPC without a numeric id, so we return whatever local triples diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index 2740e7e56..39e319890 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -26,7 +26,10 @@ import { SwmHostModeStore } from '../../src/swm/host-mode-store.js'; interface ClassifierInternals { isConfirmedPublicForHostMode(cgId: string): Promise; - getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>; + getContextGraphOnChainPolicy( + cgId: string, + options?: { forcePublishPolicyChainRead?: boolean }, + ): Promise<{ accessPolicy?: number; publishPolicy?: number }>; } interface IngestInternals { @@ -88,6 +91,18 @@ describe('GH #1124 — isConfirmedPublicForHostMode safety bias (only accessPoli g.getContextGraphOnChainPolicy = async () => { throw new Error('chain unavailable'); }; expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); }); + + it('forces a FRESH publishPolicy chain read for the admission gate (no ≤60s stale-permissive window)', async () => { + // publishPolicy is mutable on-chain; the cache is only ≤60s-TTL'd. This + // security-positive gate must re-verify on-chain, so it MUST pass + // forcePublishPolicyChainRead. (Branimir review #1239: an open→curated + // downgrade must not leave a stale-permissive admission window.) + const g = (await makeCore()) as unknown as ClassifierInternals; + let capturedOpts: { forcePublishPolicyChainRead?: boolean } | undefined; + g.getContextGraphOnChainPolicy = async (_cg, opts) => { capturedOpts = opts; return { accessPolicy: 0, publishPolicy: 1 }; }; + await g.isConfirmedPublicForHostMode('cg'); + expect(capturedOpts?.forcePublishPolicyChainRead).toBe(true); + }); }); describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintext gossip end-to-end)', () => { diff --git a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts new file mode 100644 index 000000000..1d5d5c5bb --- /dev/null +++ b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts @@ -0,0 +1,179 @@ +import { describe, it, expect } from 'vitest'; +import { ACKCollector, type ACKCollectorDeps } from '../src/ack-collector.js'; +import { StorageACKHandler, type StorageACKHandlerConfig } from '../src/storage-ack-handler.js'; +import { + computeFlatKCRootV10 as computeFlatKCRoot, + computeFlatKCMerkleLeafCountV10, +} from '../src/merkle.js'; +import { + computePublishACKDigest, + encodePublishIntent, + decodeStorageACK, + isStorageACKDecline, + STORAGE_ACK_DECLINE_CODES, +} from '@origintrail-official/dkg-core'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { ethers } from 'ethers'; +import type { Quad } from '@origintrail-official/dkg-storage'; + +/** + * Issue #1124 / PR #1239 — the *end-state* demonstration Branimir asked for. + * + * The PR's stated purpose is "public CGs **reach** storage-ACK quorum." The + * agent-side gate fix (`isConfirmedPublicForHostMode` admitting a self-signed + * public plaintext on a non-member host) is *necessary*, but the review's open + * question was whether it is *sufficient*: does the quorum actually become + * reachable on a host-mode sharded topology whose storage cores are + * NON-MEMBERS of the CG? + * + * That sub-scenario can't be reproduced on a small all-staked devnet, because + * there every core is a member of every *registered* CG (live: a CG-4 publish + * reached quorum with every ACK tagged `source=member`, including the + * host-mode node). So this proves it deterministically at the layer that + * actually decides quorum, wiring the REAL `ACKCollector` to REAL + * `StorageACKHandler`s over REAL stores. + * + * The load-bearing architectural fact: `StorageACKHandlerConfig` has NO + * membership input. A core signs a quorum-eligible ACK iff (role=core ∧ + * data-present-in-SWM ∧ merkle-matches ∧ signer-registered) — membership is + * never consulted. So a non-member host that obtained the public plaintext + * purely via the #1124 host-mode ingest contributes an ACK identical to a + * member's. These cores are seeded EXACTLY as the host-mode ingest seeds them: + * the public plaintext written into `/_shared_memory` (the same graph + * `loadSWMQuads` reads). No membership, no on-chain shard assignment. + */ +const TEST_CHAIN_ID = 31337n; +const TEST_KAV10_ADDR = '0x000000000000000000000000000000000000c10a'; + +const contextGraphId = '77'; +const cgIdBigInt = 77n; +const swmGraphUri = `did:dkg:context-graph:${contextGraphId}/_shared_memory`; + +function makeQuad(s: string, p: string, o: string): Quad { + return { subject: s, predicate: p, object: o, graph: swmGraphUri }; +} + +// The public plaintext a host-mode non-member core ingested off gossip. +const publicQuads: Quad[] = [ + makeQuad('urn:public:asset:1', 'http://schema.org/name', '"Public Knowledge Asset"'), + makeQuad('urn:public:asset:1', 'http://schema.org/description', '"reachable-quorum-demo"'), + makeQuad('urn:public:asset:1', 'http://www.w3.org/1999/02/22-rdf-syntax-ns#type', 'urn:test:Asset'), +]; +const merkleRoot = computeFlatKCRoot(publicQuads, []); +const merkleLeafCount = computeFlatKCMerkleLeafCountV10(publicQuads, []); +const publicByteSize = BigInt(publicQuads.length * 100); + +const noopBus = { emit: () => {}, on: () => {}, off: () => {}, once: () => {} }; + +/** + * Build N non-member host-mode core handlers. `seeded=true` mimics the + * post-#1124 world (the gate admitted + stored the public plaintext); + * `seeded=false` mimics the pre-#1124 world (the gate dropped it, SWM empty). + */ +async function makeNonMemberCores(count: number, seeded: boolean) { + const wallets = Array.from({ length: count }, () => ethers.Wallet.createRandom()); + const handlers = []; + for (let i = 0; i < count; i++) { + const store = new OxigraphStore(); + if (seeded) { + // EXACTLY what `ingestSwmHostModeEnvelope` writes for a public CG: the + // plaintext quads into `/_shared_memory`. Nothing about membership. + await store.insert(publicQuads.map((q) => ({ ...q }))); + } + const config: StorageACKHandlerConfig = { + nodeRole: 'core', + nodeIdentityId: BigInt(i + 1), + signerWallet: wallets[i], + contextGraphSharedMemoryUri: (cgId: string) => + `did:dkg:context-graph:${cgId}/_shared_memory`, + chainId: TEST_CHAIN_ID, + kav10Address: TEST_KAV10_ADDR, + // No `isCgCurated`, no membership hook — these are plain non-member hosts. + }; + handlers.push(new StorageACKHandler(store as any, config, noopBus as any)); + } + return { wallets, handlers }; +} + +function makeCollector(handlers: StorageACKHandler[]) { + const peers = handlers.map((_, i) => `host-${i}`); + const deps: ACKCollectorDeps = { + gossipPublish: async () => {}, + sendP2P: async (peerId, _protocol, data) => { + const idx = parseInt(peerId.replace('host-', ''), 10); + return handlers[idx].handler(data, { toString: () => peerId }); + }, + getConnectedCorePeers: () => peers, + log: () => {}, + }; + return new ACKCollector(deps); +} + +const collectArgs = { + merkleRoot, + contextGraphId: cgIdBigInt, + contextGraphIdStr: contextGraphId, + publisherPeerId: 'publisher-edge', + publicByteSize, + isPrivate: false, + kaCount: 1, + rootEntities: [] as string[], + chainId: TEST_CHAIN_ID, + kav10Address: TEST_KAV10_ADDR, + merkleLeafCount, +}; + +describe('#1124 end-state: public-CG quorum is REACHED purely via non-member host-mode cores', () => { + it('POST-FIX — 3 non-member hosts holding the host-mode-ingested plaintext reach quorum with valid signed ACKs', async () => { + const { wallets, handlers } = await makeNonMemberCores(3, /* seeded */ true); + const collector = makeCollector(handlers); + + const result = await collector.collect({ ...collectArgs }); + + // Quorum (DEFAULT_REQUIRED_ACKS = 3) reached entirely from non-members. + expect(result.acks).toHaveLength(3); + + // Every ACK is a real EIP-191 signature over the canonical V10 publish + // digest, recovering to one of the non-member host signers — i.e. each is + // a consensus-valid, on-chain-submittable ACK, not a courtesy response. + const digest = computePublishACKDigest( + TEST_CHAIN_ID, TEST_KAV10_ADDR, cgIdBigInt, merkleRoot, + 1n, publicByteSize, 1n, 0n, BigInt(merkleLeafCount), + ); + const prefixedHash = ethers.hashMessage(digest); + const hostAddresses = wallets.map((w) => w.address.toLowerCase()); + for (const ack of result.acks) { + const recovered = ethers.recoverAddress(prefixedHash, { + r: ethers.hexlify(ack.signatureR), + yParityAndS: ethers.hexlify(ack.signatureVS), + }); + expect(hostAddresses).toContain(recovered.toLowerCase()); + } + }); + + it('PRE-FIX (negative control) — with the plaintext DROPPED (empty SWM) every host DECLINEs NO_DATA, the quorum-blocking signal', async () => { + // This is the #1124 failure mode: the gate dropped the self-signed public + // plaintext, SWM stayed empty. Each host then returns NO_DATA_IN_SWM — a + // permanent decline the collector cannot count, so quorum is unreachable. + // Asserting it at the handler layer (vs. burning the collector's ~31s + // retry-then-fail budget) pins the exact decline AND proves the seeded + // data above — not some membership side-channel — is what makes quorum + // reachable. + const { handlers } = await makeNonMemberCores(3, /* seeded */ false); + const intent = encodePublishIntent({ + merkleRoot, + contextGraphId, + publisherPeerId: 'publisher-edge', + publicByteSize: Number(publicByteSize), + isPrivate: false, + kaCount: 1, + rootEntities: [], + merkleLeafCount, + }); + for (const handler of handlers) { + const decoded = decodeStorageACK(await handler.handler(intent, { toString: () => 'publisher-edge' })); + expect(isStorageACKDecline(decoded)).toBe(true); + expect(decoded.declineCode).toBe(STORAGE_ACK_DECLINE_CODES.NO_DATA_IN_SWM); + } + }); +}); From f26fe533210b56f845a2ca21548ba2af89bf0ec3 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 15:54:03 +0200 Subject: [PATCH 08/12] =?UTF-8?q?fix(#1124):=20apply=20confirmed-public=20?= =?UTF-8?q?plaintext=20into=20=5Fshared=5Fmemory=20so=20a=20non-member=20h?= =?UTF-8?q?ost=20is=20ACK-capable=20(otReviewAgent=20=F0=9F=94=B4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The host-mode gate fix admitted a confirmed-public self-signed plaintext envelope but only appended the RAW envelope to SwmHostModeStore (catchup retention) — it never applied the quads to `/_shared_memory`, which is the graph the StorageACKHandler reads (loadSWMQuads / sharedMemoryReadBothFilter). So a non-member host retained the share but still DECLINEd NO_DATA_IN_SWM when a publisher dialed it for an ACK → public-CG quorum stayed unreachable via non-member storage cores. The prior quorum test masked this by seeding the triple store directly instead of driving the real ingest. Fix: in ingestSwmHostModeEnvelope, for a CONFIRMED-PUBLIC CG only, also apply the plaintext via the member apply path — `handler.handle(data, fromPeerId, undefined, { trustedReplay: true })` — on the same already-authority-verified bytes (mirrors the LU-6 catchup-replay). For a public CG handle() skips no crypto; trustedReplay skips only the transport re-checks verifyHostModeEnvelopeAuthority already performed. The opaque append is kept for member host-catchup serving. SECURITY: the `if (confirmedPublic)` wrapper is the SOLE authority gate and is load-bearing — on a host-only core handle() CANNOT distinguish curated from public (both resolve to agentGateAddresses===null && hasPrivateAccessPolicy ===false), so confirmedPublic (accessPolicy===0 && forced-fresh publishPolicy ===1) is what guarantees public. Documented in-code so a refactor can't hoist the apply out. Tests: - host-mode-public-ingest-1124.test.ts: drives the REAL ingest end-to-end into a REAL StorageACKHandler over the same store — confirmed-public ingest → _shared_memory populated → signed quorum-eligible ACK (goes RED against the pre-fix code). Negative controls: curated (accessPolicy=1) AND public-read/ restricted-publish (accessPolicy=0, publishPolicy=0) → _shared_memory empty → NO_DATA_IN_SWM. - host-mode-quorum-bridge-1124.test.ts: scope narrowed — it isolates the collector-quorum link and points to the agent test as the real-ingest guard (no longer asserts the unverified "this is what ingest writes" claim). - issue-306-787-write-quad-validation.test.ts: reuse the shared live-daemon helper instead of a duplicated startup harness (🔵 nit). Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-swm-host.ts | 59 ++++++ .../swm/host-mode-public-ingest-1124.test.ts | 181 +++++++++++++++++- ...ssue-306-787-write-quad-validation.test.ts | 121 +++--------- .../test/host-mode-quorum-bridge-1124.test.ts | 51 ++--- 4 files changed, 295 insertions(+), 117 deletions(-) diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index 74d3eca34..f72e7a68e 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -1154,6 +1154,65 @@ export class SwmHostModeMethods extends DKGAgentBase { } } } + // GH #1124 — make a CONFIRMED-PUBLIC host-only (non-member) core ACK-CAPABLE. + // The opaque `append` below retains the raw envelope so this host can serve + // member host-catchup (LU-6 replay), but the StorageACKHandler a publisher + // dials reads `/_shared_memory` from `this.store` (loadSWMQuads / + // sharedMemoryReadBothFilter) — it has NO path into SwmHostModeStore. So + // without ALSO applying the plaintext into that triple-store graph, a + // non-member host would still DECLINE `NO_DATA_IN_SWM` and a public CG's + // storage-ACK quorum stays unreachable on a host-mode (non-member) topology + // — the exact bug this PR claims to fix. Reuse the member apply path + // (`handle`) on the SAME, already-authority-verified envelope bytes; for a + // public CG it routes the plaintext quads to the per-KA SWM layer the ACK + // handler reads (graph-agnostic merkle, no re-skolemize), so the recompute + // matches and this host signs a quorum-eligible ACK exactly as a member does. + // + // SECURITY — the `if (confirmedPublic)` wrapper is the SOLE authority gate + // for this apply, and it is LOAD-BEARING: on a host-only core `handle()` + // CANNOT distinguish curated from public (a non-member holds no local `_meta` + // allowlist nor accessPolicy, so a curated AND a public CG both resolve to + // `agentGateAddresses === null` && `hasPrivateAccessPolicy === false`, and + // `handle()` would apply plaintext for EITHER). What guarantees this CG is + // genuinely public is `isConfirmedPublicForHostMode` — accessPolicy === 0 + // (immutable) AND a FORCED-fresh publishPolicy === 1 (fail-closed on RPC + // error). DO NOT hoist this apply out of the `confirmedPublic` branch or + // reuse a `confirmedPublic` resolved further from the apply — either silently + // re-opens curated-plaintext injection into a non-member's SWM store. + // `verifyHostModeEnvelopeAuthority` already bound sig + 5-min freshness + CG + + // `publisherPeerId === fromPeerId` on these exact `data` bytes one block up, + // so `handle({ trustedReplay: true })` skips only the transport re-checks it + // already performed — for a public CG (agentGateAddresses === null) it skips + // no cryptography. Mirrors the catchup-replay call (~line 3575). + if (confirmedPublic) { + try { + const apply = await handler.handle(data, fromPeerId, undefined, { trustedReplay: true }); + if (apply.applied) { + this.log.info( + ctx, + `Host-mode applied confirmed-public SWM plaintext cg=${storageCgId} triples=${apply.insertedTriples ?? 0} (now ACK-capable)`, + ); + } else { + // Apply declined (validation / CAS / dedup). Keep going to the opaque + // append so member catchup still works; this host just won't ACK this + // share (it falls back to the pre-fix NO_DATA_IN_SWM decline). Logged + // at WARN so a SYSTEMATIC public-CG apply failure is observable here + // rather than only downstream as quorum-unmet. + const reason = 'reason' in apply ? apply.reason : 'unknown'; + this.log.warn( + ctx, + `Host-mode confirmed-public SWM apply NOT applied cg=${storageCgId}: ${reason} (host keeps opaque copy for catchup but will DECLINE NO_DATA_IN_SWM on ACK)`, + ); + } + } catch (err) { + // Never let an apply error drop the opaque retention path below. + this.log.warn( + ctx, + `Host-mode confirmed-public SWM apply threw cg=${storageCgId}: ${err instanceof Error ? err.message : String(err)} (opaque retention below unaffected)`, + ); + } + } + const seqno = await this.swmHostModeStore.append(storageCgId, data); this.log.debug( ctx, diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index 39e319890..104aacb2d 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -20,7 +20,21 @@ import { ethers } from 'ethers'; import { mkdtemp, rm } from 'node:fs/promises'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { encodeWorkspacePublishRequest } from '@origintrail-official/dkg-core'; +import { + encodeWorkspacePublishRequest, + encodePublishIntent, + decodeStorageACK, + isStorageACKDecline, + STORAGE_ACK_DECLINE_CODES, + computePublishACKDigest, + sharedMemoryReadBothFilter, + TypedEventBus, +} from '@origintrail-official/dkg-core'; +import { + StorageACKHandler, + computeFlatKCRootV10, + computeFlatKCMerkleLeafCountV10, +} from '@origintrail-official/dkg-publisher'; import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../../src/index.js'; import { SwmHostModeStore } from '../../src/swm/host-mode-store.js'; @@ -215,3 +229,168 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex expect(await entriesFor(g, cg)).toBe(0); }); }); + +/** + * GH #1124 END-STATE (otReviewAgent 🔴 round-2 #1239) — admitting the public + * plaintext into the host-mode store is necessary but NOT sufficient: the + * StorageACKHandler a publisher dials reads `/_shared_memory` from the + * triple store, NOT SwmHostModeStore. So a non-member host that only retains + * the opaque envelope still DECLINEs `NO_DATA_IN_SWM` and public-CG quorum + * stays unreachable. The fix ALSO applies the plaintext (via the member apply + * path) into the SWM graph the ACK handler reads. + * + * These tests drive the REAL `ingestSwmHostModeEnvelope` end-to-end and then a + * REAL `StorageACKHandler` over the SAME agent store — they go RED against the + * pre-fix code (which only appended the opaque envelope), unlike a test that + * seeds the triple store directly. The negative controls pin that the apply is + * gated SOLELY by `isConfirmedPublicForHostMode` (the load-bearing wrapper): + * a curated CG (accessPolicy=1) AND a public-READABLE-but-restricted-PUBLISH CG + * (accessPolicy=0, publishPolicy=0) both leave `_shared_memory` empty. + */ +describe('GH #1124 — a confirmed-public ingest makes a NON-MEMBER host ACK-capable (the quorum bridge)', () => { + const tempDirs: string[] = []; + const agents: DKGAgent[] = []; + afterEach(async () => { + await Promise.all(agents.splice(0).map((a) => a.stop().catch(() => {}).then(() => a.store.close().catch(() => {})))); + await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true }))); + }); + + const PEER = '12D3KooWHostModePublisherPeerForAckTest'; + const TEST_CHAIN_ID = 31337n; + const TEST_KAV10_ADDR = '0x000000000000000000000000000000000000c10a'; + const NQUAD = ' "AckCapable1124" .'; + + async function makeHostCore(): Promise { + const dataDir = await mkdtemp(join(tmpdir(), 'dkg-1124-ack-')); + tempDirs.push(dataDir); + const core = await DKGAgent.create({ name: 'Ack1124Host', listenHost: '127.0.0.1', dataDir, nodeRole: 'core', swmHostMode: { enabled: true } }); + agents.push(core); + const g = core as unknown as IngestInternals; + // Wire the host-mode store explicitly — ingestSwmHostModeEnvelope returns + // early when `swmHostModeStore` is unset (it's lazily inited on start()). + const store = new SwmHostModeStore({ dataDir: join(dataDir, 'swm-host'), ...SwmHostModeStore.defaultLimits() }); + await store.init(); + g.swmHostModeStore = store; + const signer = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'signer'); + g.localAgents.set(signer.agentAddress, signer); + g.defaultAgentAddress = signer.agentAddress; + return core; + } + + // The publish envelope the host receives off gossip (numeric cg — the ACK + // digest requires a numeric on-chain id). publisherPeerId === sender so the + // anti-spoof bind passes. + const publishEnvelope = (g: IngestInternals, cg: string) => g.encodeWorkspaceGossipMessage(cg, encodeWorkspacePublishRequest({ + contextGraphId: cg, + nquads: new TextEncoder().encode(NQUAD), + manifest: [{ rootEntity: 'urn:ack1124:s' }], + publisherPeerId: PEER, + shareOperationId: `op-ack-${cg}`, + timestampMs: 1_700_000_000_000, + })); + + // Read the SWM graph EXACTLY as StorageACKHandler.loadSWMQuads does. + async function readSwmQuads(core: DKGAgent, cg: string) { + const swmGraphUri = `did:dkg:context-graph:${cg}/_shared_memory`; + const sparql = `CONSTRUCT { ?s ?p ?o } WHERE { GRAPH ?g { ?s ?p ?o } ${sharedMemoryReadBothFilter(swmGraphUri)} }`; + const res = await core.store.query(sparql); + return res.type === 'quads' ? res.quads : []; + } + + function ackHandler(core: DKGAgent, signer: ethers.Wallet) { + return new StorageACKHandler(core.store as any, { + nodeRole: 'core', + nodeIdentityId: 7n, + signerWallet: signer, + contextGraphSharedMemoryUri: (id: string) => `did:dkg:context-graph:${id}/_shared_memory`, + chainId: TEST_CHAIN_ID, + kav10Address: TEST_KAV10_ADDR, + }, new TypedEventBus() as any); + } + + it('CONFIRMED-PUBLIC: real ingest applies plaintext to _shared_memory → host signs a quorum-eligible ACK (not NO_DATA)', async () => { + const core = await makeHostCore(); + const g = core as unknown as IngestInternals; + const cg = '4242001'; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); + + // The REAL ingest path — NOT a direct store.insert. + const env = await publishEnvelope(g, cg); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + + // 1) The plaintext landed in the ACK-readable SWM scope (empty pre-fix). + const quads = await readSwmQuads(core, cg); + expect(quads.length).toBe(1); + expect(quads[0].subject).toBe('urn:ack1124:s'); + expect(quads[0].object).toContain('AckCapable1124'); + + // 2) A REAL StorageACKHandler over the SAME store now signs an ACK. The + // publisher's claimed root = the flat-KC root over those quads (what a + // publisher that published this KA would submit); the host recomputes + // over its applied copy and they match by construction. + const merkleRoot = computeFlatKCRootV10(quads, []); + const leafCount = computeFlatKCMerkleLeafCountV10(quads, []); + const byteSize = new TextEncoder().encode(NQUAD).length; + const signer = ethers.Wallet.createRandom(); + const intent = encodePublishIntent({ + merkleRoot, contextGraphId: cg, publisherPeerId: PEER, + publicByteSize: byteSize, isPrivate: false, kaCount: 1, + rootEntities: [], merkleLeafCount: leafCount, + }); + const ack = decodeStorageACK(await ackHandler(core, signer).handler(intent, { toString: () => PEER })); + + expect(isStorageACKDecline(ack)).toBe(false); + // The ACK is a real EIP-191 signature over the canonical V10 digest. + const digest = computePublishACKDigest( + TEST_CHAIN_ID, TEST_KAV10_ADDR, BigInt(cg), merkleRoot, + 1n, BigInt(byteSize), 1n, 0n, BigInt(leafCount), + ); + const recovered = ethers.recoverAddress(ethers.hashMessage(digest), { + r: ethers.hexlify(ack.coreNodeSignatureR instanceof Uint8Array ? ack.coreNodeSignatureR : new Uint8Array(ack.coreNodeSignatureR)), + yParityAndS: ethers.hexlify(ack.coreNodeSignatureVS instanceof Uint8Array ? ack.coreNodeSignatureVS : new Uint8Array(ack.coreNodeSignatureVS)), + }); + expect(recovered.toLowerCase()).toBe(signer.address.toLowerCase()); + }); + + it('CURATED (accessPolicy 1): real ingest does NOT apply → _shared_memory empty → StorageACKHandler DECLINEs NO_DATA', async () => { + const core = await makeHostCore(); + const g = core as unknown as IngestInternals; + const cg = '4242002'; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1, publishPolicy: 0 }); + + const env = await publishEnvelope(g, cg); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + + expect((await readSwmQuads(core, cg)).length).toBe(0); + const signer = ethers.Wallet.createRandom(); + const intent = encodePublishIntent({ + merkleRoot: new Uint8Array(32), contextGraphId: cg, publisherPeerId: PEER, + publicByteSize: 10, isPrivate: false, kaCount: 1, rootEntities: ['urn:ack1124:s'], + }); + const ack = decodeStorageACK(await ackHandler(core, signer).handler(intent, { toString: () => PEER })); + expect(isStorageACKDecline(ack)).toBe(true); + expect(ack.declineCode).toBe(STORAGE_ACK_DECLINE_CODES.NO_DATA_IN_SWM); + }); + + it('PUBLIC READ but RESTRICTED PUBLISH (accessPolicy 0, publishPolicy 0): real ingest does NOT apply → NO_DATA (the #1239-r3 case)', async () => { + // The case a naive accessPolicy-only check would miss: readable ≠ self- + // publishable. The apply must stay gated on BOTH axes (publishPolicy===1). + const core = await makeHostCore(); + const g = core as unknown as IngestInternals; + const cg = '4242003'; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 0 }); + + const env = await publishEnvelope(g, cg); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + + expect((await readSwmQuads(core, cg)).length).toBe(0); + const signer = ethers.Wallet.createRandom(); + const intent = encodePublishIntent({ + merkleRoot: new Uint8Array(32), contextGraphId: cg, publisherPeerId: PEER, + publicByteSize: 10, isPrivate: false, kaCount: 1, rootEntities: ['urn:ack1124:s'], + }); + const ack = decodeStorageACK(await ackHandler(core, signer).handler(intent, { toString: () => PEER })); + expect(isStorageACKDecline(ack)).toBe(true); + expect(ack.declineCode).toBe(STORAGE_ACK_DECLINE_CODES.NO_DATA_IN_SWM); + }); +}); diff --git a/packages/cli/test/issue-306-787-write-quad-validation.test.ts b/packages/cli/test/issue-306-787-write-quad-validation.test.ts index 5fcd62d51..0c55f4d2b 100644 --- a/packages/cli/test/issue-306-787-write-quad-validation.test.ts +++ b/packages/cli/test/issue-306-787-write-quad-validation.test.ts @@ -13,128 +13,65 @@ * agent write path. This test also asserts the POSITIVE path — well-formed * {subject,predicate,object} quads (graph optional) still succeed — so the * validation can't regress valid writes. One real auth-enabled daemon against - * the cli suite's shared Hardhat node; no chain mocks. + * the cli suite's shared Hardhat node; no chain mocks. Daemon lifecycle reuses + * the shared `live-daemon` helper (startup config, wallet seeding, readiness, + * token loading, port allocation) so it can't drift from the other cli live + * tests. */ import { beforeAll, afterAll, describe, expect, it } from 'vitest'; -import { spawn, type ChildProcess } from 'node:child_process'; -import { mkdtemp, writeFile, rm, readFile } from 'node:fs/promises'; -import { existsSync } from 'node:fs'; -import { join, dirname } from 'node:path'; -import { fileURLToPath } from 'node:url'; -import { tmpdir } from 'node:os'; -import { ethers } from 'ethers'; -import { getSharedContext, HARDHAT_KEYS } from '../../chain/test/evm-test-context.js'; +import { startLiveDaemon, stopLiveDaemon, postJson, type LiveDaemon } from './helpers/live-daemon.js'; -const __dirname = dirname(fileURLToPath(import.meta.url)); -const CLI_ENTRY = join(__dirname, '..', 'dist', 'cli.js'); -const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms)); - -interface Daemon { home: string; apiPort: number; child: ChildProcess; token: string; } -let daemon: Daemon | null = null; +let daemon: LiveDaemon | undefined; const CG = 'wq-validation-cg'; -async function startDaemon(): Promise { - if (!existsSync(CLI_ENTRY)) throw new Error(`CLI not built at ${CLI_ENTRY}. Run the package build first.`); - const home = await mkdtemp(join(tmpdir(), 'dkg-wq-validation-')); - const apiPort = 19760 + Math.floor(Math.random() * 180); - const listenPort = apiPort + 400; - const { rpcUrl, hubAddress } = getSharedContext(); - await writeFile(join(home, 'config.json'), JSON.stringify({ - name: 'wq-validation-test', apiPort, listenPort, apiHost: '127.0.0.1', nodeRole: 'edge', relay: 'none', - auth: { enabled: true }, - store: { backend: 'oxigraph-worker', options: { path: join(home, 'store.nq') } }, - chain: { type: 'evm', rpcUrl, hubAddress, chainId: 'evm:31337' }, contextGraphs: [], - })); - const coreOp = new ethers.Wallet(HARDHAT_KEYS.CORE_OP); - await writeFile(join(home, 'wallets.json'), - JSON.stringify({ wallets: [{ address: coreOp.address, privateKey: coreOp.privateKey }] }, null, 2) + '\n', { mode: 0o600 }); - const child = spawn('node', [CLI_ENTRY, 'daemon-worker'], { - env: { ...process.env, DKG_HOME: home, DKG_API_PORT: String(apiPort), DKG_NO_BLUE_GREEN: '1', DKG_DISABLE_TELEMETRY: '1' }, - stdio: 'ignore', - }); - for (let i = 0; i < 90; i++) { - if (child.exitCode !== null) throw new Error(`Daemon exited early (${child.exitCode})`); - try { if ((await fetch(`http://127.0.0.1:${apiPort}/api/status`)).ok) break; } catch { /* not ready */ } - await sleep(500); - if (i === 89) throw new Error('Daemon did not become ready within 45s'); - } - const raw = await readFile(join(home, 'auth.token'), 'utf-8'); - const token = raw.split('\n').map((l) => l.trim()).find((l) => l.length > 0 && !l.startsWith('#')); - if (!token) throw new Error('No auth token'); - return { home, apiPort, child, token }; -} - -const url = (p: string) => `http://127.0.0.1:${daemon!.apiPort}${p}`; -const headers = () => ({ 'Content-Type': 'application/json', Authorization: `Bearer ${daemon!.token}` }); - beforeAll(async () => { - daemon = await startDaemon(); - const res = await fetch(url('/api/context-graph/create'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ id: CG, name: 'WQ Validation CG', accessPolicy: 0 }), + daemon = await startLiveDaemon({ authEnabled: true }); + const { status, body } = await postJson(daemon, '/api/context-graph/create', { + id: CG, name: 'WQ Validation CG', accessPolicy: 0, }); - if (!res.ok) throw new Error(`CG create failed: ${res.status} ${await res.text()}`); + if (status >= 300) throw new Error(`CG create failed: ${status} ${JSON.stringify(body)}`); }, 120_000); afterAll(async () => { - if (daemon) { - daemon.child.kill('SIGTERM'); - await sleep(1500); - if (daemon.child.exitCode === null) daemon.child.kill('SIGKILL'); - await rm(daemon.home, { recursive: true, force: true }).catch(() => {}); - } + await stopLiveDaemon(daemon); }); describe('GH #787 — POST /api/shared-memory/write quad-shape validation', () => { it('returns 4xx (not 500) for N-Quad string-shaped quads', async () => { - const res = await fetch(url('/api/shared-memory/write'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, quads: [' "v" .'] }), + const { status } = await postJson(daemon!, '/api/shared-memory/write', { + contextGraphId: CG, quads: [' "v" .'], }); - expect(res.status).not.toBe(500); - expect(res.status).toBeGreaterThanOrEqual(400); - expect(res.status).toBeLessThan(500); + expect(status).not.toBe(500); + expect(status).toBeGreaterThanOrEqual(400); + expect(status).toBeLessThan(500); }); it('accepts well-formed object quads (regression: valid SWM write still succeeds)', async () => { - const res = await fetch(url('/api/shared-memory/write'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, quads: [ - { subject: 'urn:wq:s787', predicate: 'http://schema.org/name', object: '"ok787"' }, - ] }), + const { status, body } = await postJson(daemon!, '/api/shared-memory/write', { + contextGraphId: CG, quads: [{ subject: 'urn:wq:s787', predicate: 'http://schema.org/name', object: '"ok787"' }], }); - expect(res.status, await res.text().catch(() => '')).toBe(200); + expect(status, JSON.stringify(body)).toBe(200); }); }); describe('GH #306 — POST /api/knowledge-assets/{name}/wm/write quad-shape validation', () => { it('returns 4xx (not 500) for N-Quad string-shaped quads', async () => { - const created = await fetch(url('/api/knowledge-assets'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, name: 'ka-306' }), - }); + const created = await postJson(daemon!, '/api/knowledge-assets', { contextGraphId: CG, name: 'ka-306' }); expect(created.status, 'KA create precondition').toBeLessThan(300); - const res = await fetch(url('/api/knowledge-assets/ka-306/wm/write'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, quads: [' .'] }), + const { status } = await postJson(daemon!, '/api/knowledge-assets/ka-306/wm/write', { + contextGraphId: CG, quads: [' .'], }); - expect(res.status).not.toBe(500); - expect(res.status).toBeGreaterThanOrEqual(400); - expect(res.status).toBeLessThan(500); + expect(status).not.toBe(500); + expect(status).toBeGreaterThanOrEqual(400); + expect(status).toBeLessThan(500); }); it('accepts well-formed object quads (regression: valid wm/write still succeeds)', async () => { - const created = await fetch(url('/api/knowledge-assets'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, name: 'ka-306-ok' }), - }); + const created = await postJson(daemon!, '/api/knowledge-assets', { contextGraphId: CG, name: 'ka-306-ok' }); expect(created.status).toBeLessThan(300); - const res = await fetch(url('/api/knowledge-assets/ka-306-ok/wm/write'), { - method: 'POST', headers: headers(), - body: JSON.stringify({ contextGraphId: CG, quads: [ - { subject: 'urn:wq:s306', predicate: 'http://schema.org/name', object: '"ok306"' }, - ] }), + const { status, body } = await postJson(daemon!, '/api/knowledge-assets/ka-306-ok/wm/write', { + contextGraphId: CG, quads: [{ subject: 'urn:wq:s306', predicate: 'http://schema.org/name', object: '"ok306"' }], }); - expect(res.status, await res.text().catch(() => '')).toBe(200); + expect(status, JSON.stringify(body)).toBe(200); }); }); diff --git a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts index 1d5d5c5bb..b12551f85 100644 --- a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts +++ b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts @@ -17,30 +17,30 @@ import { ethers } from 'ethers'; import type { Quad } from '@origintrail-official/dkg-storage'; /** - * Issue #1124 / PR #1239 — the *end-state* demonstration Branimir asked for. + * Issue #1124 / PR #1239 — the COLLECTOR half of the end-state: given the public + * plaintext is present in N non-member hosts' SWM, the publisher's quorum is + * reachable purely from them. * - * The PR's stated purpose is "public CGs **reach** storage-ACK quorum." The - * agent-side gate fix (`isConfirmedPublicForHostMode` admitting a self-signed - * public plaintext on a non-member host) is *necessary*, but the review's open - * question was whether it is *sufficient*: does the quorum actually become - * reachable on a host-mode sharded topology whose storage cores are - * NON-MEMBERS of the CG? + * SCOPE — read this with its sibling. The claim "the real host-mode ingest + * actually WRITES the public plaintext into `/_shared_memory` (the graph the + * ACK handler reads)" is proved by the agent-side test that drives the REAL + * `ingestSwmHostModeEnvelope` end-to-end into a real `StorageACKHandler`: + * `packages/agent/test/swm/host-mode-public-ingest-1124.test.ts` + * ("a confirmed-public ingest makes a NON-MEMBER host ACK-capable"). THIS test + * does NOT drive the ingest path — it seeds the SWM graph directly and isolates + * the next link: that the `ACKCollector` reaches quorum from N non-member cores + * once their SWM holds the share. (A direct seed alone would stay green even if + * ingest never populated `_shared_memory`, which is exactly why the agent-side + * real-ingest test — not this one — is the guard for the apply.) * - * That sub-scenario can't be reproduced on a small all-staked devnet, because - * there every core is a member of every *registered* CG (live: a CG-4 publish - * reached quorum with every ACK tagged `source=member`, including the - * host-mode node). So this proves it deterministically at the layer that - * actually decides quorum, wiring the REAL `ACKCollector` to REAL - * `StorageACKHandler`s over REAL stores. - * - * The load-bearing architectural fact: `StorageACKHandlerConfig` has NO - * membership input. A core signs a quorum-eligible ACK iff (role=core ∧ - * data-present-in-SWM ∧ merkle-matches ∧ signer-registered) — membership is - * never consulted. So a non-member host that obtained the public plaintext - * purely via the #1124 host-mode ingest contributes an ACK identical to a - * member's. These cores are seeded EXACTLY as the host-mode ingest seeds them: - * the public plaintext written into `/_shared_memory` (the same graph - * `loadSWMQuads` reads). No membership, no on-chain shard assignment. + * Why prove it here at the collector layer at all: the NON-member sub-scenario + * can't be reproduced on a small all-staked devnet, where every core is a member + * of every *registered* CG (live: a CG-4 publish reached quorum with every ACK + * tagged `source=member`, including the host-mode node). The load-bearing + * architectural fact: `StorageACKHandlerConfig` has NO membership input. A core + * signs a quorum-eligible ACK iff (role=core ∧ data-present-in-SWM ∧ + * merkle-matches ∧ signer-registered) — membership is never consulted, so a + * non-member host's ACK is consensus-identical to a member's. */ const TEST_CHAIN_ID = 31337n; const TEST_KAV10_ADDR = '0x000000000000000000000000000000000000c10a'; @@ -76,8 +76,11 @@ async function makeNonMemberCores(count: number, seeded: boolean) { for (let i = 0; i < count; i++) { const store = new OxigraphStore(); if (seeded) { - // EXACTLY what `ingestSwmHostModeEnvelope` writes for a public CG: the - // plaintext quads into `/_shared_memory`. Nothing about membership. + // Seed the SWM graph the ACK handler reads. That the REAL host-mode ingest + // actually produces this state (writes the public plaintext into + // `/_shared_memory`) is proved separately by the agent-side real-ingest + // test (see the file header); here we take it as given and isolate the + // collector's quorum behaviour. Nothing about membership. await store.insert(publicQuads.map((q) => ({ ...q }))); } const config: StorageACKHandlerConfig = { From f850ea5fe22bb503293168df829e1146b0325b37 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 16:19:46 +0200 Subject: [PATCH 09/12] perf(#1124): lazy confirmedPublic + short publishPolicy cache window (Branimir review follow-on) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The force-fresh publishPolicy read (the strict choice from the #1 fix) runs on EVERY host-mode envelope via isConfirmedPublicForHostMode at the top of ingestSwmHostModeEnvelope — but `confirmedPublic` only gates anything when !isCiphertext, and verifyHostModeEnvelopeAuthority ignores allowSelfSignedForPublicCg whenever an allowlist exists. So the dominant ciphertext/curated path was paying a synchronous eth_call to compute a value it discards, and the public-plaintext path had no rate cap (spammed gossip could amplify into per-message chain RPCs) — undercutting the feature meant to scale public CGs. Two changes, keeping the strictness: 1. Lazy-compute: `const confirmedPublic = !isCiphertext && await isConfirmedPublicForHostMode(...)` — skips the policy resolution entirely for ciphertext (security-preserving: a ciphertext-on-public envelope just stays on the curated path / opaque append and heals via catchup). 2. Short cache window instead of force-every-time: replace getContextGraphOnChainPolicy's `forcePublishPolicyChainRead` with `publishPolicyMaxCacheAgeMs`; the admission gate passes 5s (HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS). This bounds open→curated downgrade staleness to seconds AND rate-caps the chain RPC to ~1 per window per CG (the resolver writes through to the same cache). Still fail-closed on RPC error; accessPolicy stays immutable/un-TTL'd. Tests: on-chain-policy reworked to prove within-window→cached/no-RPC, beyond-window→re-verify, and the 6s entry still fresh under the 60s default. host-mode-public-ingest: the gate passes a short window (≤10s), and a real CIPHERTEXT envelope triggers ZERO getContextGraphOnChainPolicy calls. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-cg-registry.ts | 37 +++++---- packages/agent/src/dkg-agent-swm-host.ts | 48 ++++++++---- .../test/dkg-agent-on-chain-policy.test.ts | 77 ++++++++++++------- .../swm/host-mode-public-ingest-1124.test.ts | 43 +++++++++-- 4 files changed, 140 insertions(+), 65 deletions(-) diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 4e900c309..d52e49895 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -476,18 +476,23 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { */ async getContextGraphOnChainPolicy(this: DKGAgent, contextGraphId: string, options?: { /** - * Force a fresh chain RPC for `publishPolicy`, ignoring the (≤60s-TTL) - * cache. `publishPolicy` is mutable on-chain (`PublishPolicyUpdated`) and the - * agent has no event watcher, so the cache can be stale-PERMISSIVE for up to - * the TTL after an owner downgrades open→curated publish. Most callers (e.g. - * the import-artifact owner guard) tolerate that window, but a SECURITY- - * POSITIVE admission decision (host-mode self-signed plaintext ingest, see - * isConfirmedPublicForHostMode) must NOT — a stale `publishPolicy=1` there - * would admit a non-authorized write. With this set, the publishPolicy cache - * is treated as always-stale so the chain RPC re-verifies; an RPC - * failure/timeout leaves it undefined → the caller fails closed. + * Cap the age (ms) of a cached `publishPolicy` entry this call will accept; + * default `ON_CHAIN_PUBLISH_POLICY_CACHE_TTL_MS` (60s). `publishPolicy` is + * mutable on-chain (`PublishPolicyUpdated`) and the agent has no event + * watcher, so the cache can be stale-PERMISSIVE for up to its age after an + * owner downgrades open→curated publish. Most callers (e.g. the + * import-artifact owner guard) tolerate the full 60s. A SECURITY-POSITIVE + * admission decision (host-mode self-signed plaintext ingest, see + * `isConfirmedPublicForHostMode`) passes a SHORT window + * (`HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS` = 5s): an open→curated + * downgrade is then honored within seconds, AND — because the resolver + * writes through to this same cache — the chain RPC is rate-capped to ~1 + * per window per CG instead of an `eth_call` on every admitted envelope + * (Branimir review #1239 follow-on). An RPC failure/timeout still leaves + * `publishPolicy` undefined → the caller fails closed. (`accessPolicy` is + * immutable on-chain, so its cache read below is left un-TTL'd.) */ - forcePublishPolicyChainRead?: boolean; + publishPolicyMaxCacheAgeMs?: number; }): Promise<{ accessPolicy?: number; publishPolicy?: number; @@ -504,12 +509,14 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { // escalate privilege (gossip decrypt is gated by sender-key // issuance) and stale-restrictive only causes a transient deny. const isPublishPolicyCacheFresh = (key: string): boolean => { - // A security-positive caller can force a fresh chain re-verify (the cache - // may be stale-permissive for up to the TTL after an open→curated downgrade). - if (options?.forcePublishPolicyChainRead) return false; const fetchedAt = this.onChainPublishPolicyCacheUpdatedAt.get(key); if (fetchedAt === undefined) return false; - return Date.now() - fetchedAt <= ON_CHAIN_PUBLISH_POLICY_CACHE_TTL_MS; + // A security-positive caller can shrink the accepted cache age (e.g. the + // host-mode admission gate passes ~5s) so an open→curated downgrade is + // re-verified within seconds, while still rate-capping the chain RPC to + // ~1 per window per CG. Default is the general 60s TTL. + const maxAge = options?.publishPolicyMaxCacheAgeMs ?? ON_CHAIN_PUBLISH_POLICY_CACHE_TTL_MS; + return Date.now() - fetchedAt <= maxAge; }; let publishPolicy = isPublishPolicyCacheFresh(contextGraphId) ? this.onChainPublishPolicyCache.get(contextGraphId) diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index f72e7a68e..d1fae69e9 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -388,6 +388,16 @@ import type { DKGAgent } from './dkg-agent.js'; const DEFAULT_HOST_MODE_RECONCILE_BATCH_SIZE = 32; +/** + * Max age (ms) of a cached `publishPolicy` value the host-mode self-signed + * admission gate (`isConfirmedPublicForHostMode`) will trust. Deliberately + * short: it bounds the open→curated downgrade staleness to a few seconds + * (vs the general 60s `ON_CHAIN_PUBLISH_POLICY_CACHE_TTL_MS`) AND rate-caps the + * chain RPC to ~1 per window per CG, so spammed public-plaintext gossip can't + * amplify into a per-message `eth_call` (Branimir review #1239 follow-on). + */ +const HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS = 5_000; + function normalizeHostModeReconcileBatchSize(value: number | undefined): number { if (typeof value !== 'number' || !Number.isFinite(value)) return DEFAULT_HOST_MODE_RECONCILE_BATCH_SIZE; return Math.max(1, Math.floor(value)); @@ -701,16 +711,18 @@ export class SwmHostModeMethods extends DKGAgentBase { // sharded topology). Both must positively resolve to their open value; any // undefined (unknown) → false (safe). try { - // `forcePublishPolicyChainRead`: publishPolicy is mutable on-chain and the - // cache is only ≤60s-TTL'd, so it could be stale-PERMISSIVE for up to the - // TTL after an owner downgrades open→curated publish. This is a + // `publishPolicyMaxCacheAgeMs`: publishPolicy is mutable on-chain and the + // general cache is ≤60s-TTL'd, so it could be stale-PERMISSIVE for up to + // the TTL after an owner downgrades open→curated publish. This is a // security-positive gate (it admits a self-signed plaintext write that host - // catchup later applies under trustedReplay), so it must re-verify the - // publishPolicy on-chain rather than trust the cached value. An RPC - // failure/timeout leaves publishPolicy undefined → we fail CLOSED (drop; - // the share heals via retry/catchup once the policy re-resolves). + // catchup later applies under trustedReplay), so it accepts only a SHORT + // (~5s) cache window — bounding the downgrade staleness to seconds while + // rate-capping the chain RPC to ~1 per window per CG (vs an eth_call on + // every admitted envelope). An RPC failure/timeout leaves publishPolicy + // undefined → we fail CLOSED (drop; the share heals via retry/catchup + // once the policy re-resolves). const { accessPolicy, publishPolicy } = await this.getContextGraphOnChainPolicy( - contextGraphId, { forcePublishPolicyChainRead: true }, + contextGraphId, { publishPolicyMaxCacheAgeMs: HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS }, ); return accessPolicy === 0 && publishPolicy === 1; } catch { @@ -1051,11 +1063,21 @@ export class SwmHostModeMethods extends DKGAgentBase { } // GH #1124 — a curated CG MUST carry ciphertext, so a non-ciphertext // envelope there is garbage → drop early. A CONFIRMED-public (open) CG - // legitimately gossips PLAINTEXT SWM. Resolve the public flag ONCE - // (key-independent — see isConfirmedPublicForHostMode) and reuse it for both - // the plaintext gate and the authority check. UNKNOWN CGs stay on the drop - // path (safe; member catchup heals once the policy resolves). - const confirmedPublic = await this.isConfirmedPublicForHostMode(storageCgId); + // legitimately gossips PLAINTEXT SWM. Resolve the public flag and reuse it + // for both the plaintext gate and the authority check. UNKNOWN CGs stay on + // the drop path (safe; member catchup heals once the policy resolves). + // + // LAZY by design (Branimir review #1239 follow-on): `confirmedPublic` only + // ever GATES anything when `!isCiphertext` — line 1059 below reads it only + // then, and `verifyHostModeEnvelopeAuthority` ignores + // `allowSelfSignedForPublicCg` whenever an allowlist exists. So short-circuit + // on `!isCiphertext` to skip the (now chain-backed) policy resolution + // entirely on the dominant CIPHERTEXT/curated path — otherwise the bulk of + // host-mode traffic would pay a synchronous eth_call to compute a value it + // then discards. Security-preserving: a ciphertext envelope on a public CG + // just stays in the curated authority path / opaque append and heals via + // catchup. + const confirmedPublic = !isCiphertext && await this.isConfirmedPublicForHostMode(storageCgId); if (!isCiphertext && !confirmedPublic) return; // Authority check. Curated traffic verifies the envelope signature against diff --git a/packages/agent/test/dkg-agent-on-chain-policy.test.ts b/packages/agent/test/dkg-agent-on-chain-policy.test.ts index 28afa38d3..f4b9716ee 100644 --- a/packages/agent/test/dkg-agent-on-chain-policy.test.ts +++ b/packages/agent/test/dkg-agent-on-chain-policy.test.ts @@ -455,37 +455,56 @@ describe('DKGAgent.getContextGraphOnChainPolicy', () => { }); // Branimir review on #1239 — the host-mode self-signed admission gate - // (`isConfirmedPublicForHostMode`) is the first SECURITY-POSITIVE consumer of - // the cached `publishPolicy`, so it must NOT tolerate the ≤60s stale-permissive - // window: a host with a fresh `publishPolicy=1` entry would otherwise admit a - // self-signed plaintext write for up to the TTL after an owner downgrades - // open→curated. `forcePublishPolicyChainRead` treats the cache as always-stale - // so the chain RPC re-verifies. Same state, two answers: cached `1` without the - // flag, fresh-from-chain `0` with it. - it('forcePublishPolicyChainRead bypasses a FRESH publishPolicy cache and re-verifies on-chain (Branimir #1239)', async () => { - const getContextGraphPublishPolicy = recorder(async () => ({ - publishPolicy: 0, // the owner just flipped open → curated - publishAuthority: '0x0000000000000000000000000000000000000000', - })); - const stub = makeStub({ - subscribedContextGraphs: new Map([['cg-force', { onChainId: '88' }]]), - // FRESH cache says "1" (open) — seeded just now, well within the TTL. - onChainPublishPolicyCache: new Map([['cg-force', 1]]), - onChainPublishPolicyCacheUpdatedAt: new Map([['cg-force', Date.now()]]), - onChainAccessPolicyCache: new Map([['cg-force', 0]]), + // (`isConfirmedPublicForHostMode`) is a SECURITY-POSITIVE consumer of the + // cached `publishPolicy`, so it passes a SHORT `publishPolicyMaxCacheAgeMs` + // (~5s) instead of the general 60s TTL. The follow-on goal (per the re-review) + // is BOTH directions at once: within the short window the cache is trusted so + // the chain RPC is rate-capped to ~1 per window per CG (no eth_call per + // envelope), and PAST it the chain re-verifies so an open→curated downgrade is + // caught within seconds (not up to 60s). + it('publishPolicyMaxCacheAgeMs: within-window cache is trusted (no RPC); a beyond-window entry re-verifies on-chain (Branimir #1239 follow-on)', async () => { + const getPolicy = (stub: any, cg: string, maxAgeMs: number) => + (DKGAgent.prototype as any).getContextGraphOnChainPolicy.call(stub, cg, { publishPolicyMaxCacheAgeMs: maxAgeMs }); + + // (a) FRESH entry (age 0) within a 5s window → cached "1" used, NO RPC. + const freshRpc = recorder(async () => ({ publishPolicy: 0, publishAuthority: '0x0000000000000000000000000000000000000000' })); + const fresh = makeStub({ + subscribedContextGraphs: new Map([['cg-w', { onChainId: '88' }]]), + onChainPublishPolicyCache: new Map([['cg-w', 1]]), + onChainPublishPolicyCacheUpdatedAt: new Map([['cg-w', Date.now()]]), + onChainAccessPolicyCache: new Map([['cg-w', 0]]), isContextGraphRegistered: recorder(async () => true), - chain: { getContextGraphPublishPolicy }, + chain: { getContextGraphPublishPolicy: freshRpc }, + }); + expect(await getPolicy(fresh, 'cg-w', 5_000)).toEqual({ accessPolicy: 0, publishPolicy: 1 }); + expect(freshRpc.calls).toEqual([]); // rate-capped: no eth_call within the window + + // (b) entry OLDER than the 5s window (6s) → bypassed, chain RPC re-verifies → "0". + const staleRpc = recorder(async () => ({ publishPolicy: 0, publishAuthority: '0x0000000000000000000000000000000000000000' })); + const stale = makeStub({ + subscribedContextGraphs: new Map([['cg-w', { onChainId: '88' }]]), + onChainPublishPolicyCache: new Map([['cg-w', 1]]), + onChainPublishPolicyCacheUpdatedAt: new Map([['cg-w', Date.now() - 6_000]]), + onChainAccessPolicyCache: new Map([['cg-w', 0]]), + isContextGraphRegistered: recorder(async () => true), + chain: { getContextGraphPublishPolicy: staleRpc }, + }); + expect(await getPolicy(stale, 'cg-w', 5_000)).toEqual({ accessPolicy: 0, publishPolicy: 0 }); + expect(staleRpc.calls.at(-1)).toEqual([88n]); + + // (c) the SAME 6s-old entry is still FRESH under the default 60s TTL — the + // short window is a per-caller tightening, not a global behaviour change. + const defaultRpc = recorder(async () => ({ publishPolicy: 0, publishAuthority: '0x0000000000000000000000000000000000000000' })); + const def = makeStub({ + subscribedContextGraphs: new Map([['cg-w', { onChainId: '88' }]]), + onChainPublishPolicyCache: new Map([['cg-w', 1]]), + onChainPublishPolicyCacheUpdatedAt: new Map([['cg-w', Date.now() - 6_000]]), + onChainAccessPolicyCache: new Map([['cg-w', 0]]), + isContextGraphRegistered: recorder(async () => true), + chain: { getContextGraphPublishPolicy: defaultRpc }, }); - // Default (no flag): the fresh cache hit returns the stale-permissive "1". - expect(await callPolicy(stub, 'cg-force')).toEqual({ accessPolicy: 0, publishPolicy: 1 }); - expect(getContextGraphPublishPolicy.calls).toEqual([]); // no RPC — cache hit - // Forced: the cache is ignored, the chain RPC re-verifies → "0" (curated), - // so the admission gate sees the real (downgraded) policy and fails closed. - const forced = await (DKGAgent.prototype as any).getContextGraphOnChainPolicy.call( - stub, 'cg-force', { forcePublishPolicyChainRead: true }, - ); - expect(forced).toEqual({ accessPolicy: 0, publishPolicy: 0 }); - expect(getContextGraphPublishPolicy.calls.at(-1)).toEqual([88n]); + expect(await callPolicy(def, 'cg-w')).toEqual({ accessPolicy: 0, publishPolicy: 1 }); + expect(defaultRpc.calls).toEqual([]); // 6s < 60s default TTL → cache hit, no RPC }); // Round 3 — degenerate state: registered locally but the on-chain diff --git a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts index 104aacb2d..93b8773e1 100644 --- a/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -23,6 +23,8 @@ import { join } from 'node:path'; import { encodeWorkspacePublishRequest, encodePublishIntent, + encodeEncryptedWorkspacePayload, + ENCRYPTED_WORKSPACE_ENVELOPE_TYPE, decodeStorageACK, isStorageACKDecline, STORAGE_ACK_DECLINE_CODES, @@ -42,7 +44,7 @@ interface ClassifierInternals { isConfirmedPublicForHostMode(cgId: string): Promise; getContextGraphOnChainPolicy( cgId: string, - options?: { forcePublishPolicyChainRead?: boolean }, + options?: { publishPolicyMaxCacheAgeMs?: number }, ): Promise<{ accessPolicy?: number; publishPolicy?: number }>; } @@ -106,16 +108,18 @@ describe('GH #1124 — isConfirmedPublicForHostMode safety bias (only accessPoli expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); }); - it('forces a FRESH publishPolicy chain read for the admission gate (no ≤60s stale-permissive window)', async () => { - // publishPolicy is mutable on-chain; the cache is only ≤60s-TTL'd. This - // security-positive gate must re-verify on-chain, so it MUST pass - // forcePublishPolicyChainRead. (Branimir review #1239: an open→curated - // downgrade must not leave a stale-permissive admission window.) + it('passes a SHORT publishPolicy cache window for the admission gate (bounded staleness + rate-capped RPC)', async () => { + // publishPolicy is mutable on-chain; the general cache is ≤60s-TTL'd. This + // security-positive gate passes a SHORT window so an open→curated downgrade + // is re-verified within seconds AND the chain RPC is rate-capped to ~1 per + // window per CG — not force-every-time (Branimir review #1239 follow-on). const g = (await makeCore()) as unknown as ClassifierInternals; - let capturedOpts: { forcePublishPolicyChainRead?: boolean } | undefined; + let capturedOpts: { publishPolicyMaxCacheAgeMs?: number } | undefined; g.getContextGraphOnChainPolicy = async (_cg, opts) => { capturedOpts = opts; return { accessPolicy: 0, publishPolicy: 1 }; }; await g.isConfirmedPublicForHostMode('cg'); - expect(capturedOpts?.forcePublishPolicyChainRead).toBe(true); + // A short window: positive, and far under the general 60s TTL. + expect(capturedOpts?.publishPolicyMaxCacheAgeMs).toBeGreaterThan(0); + expect(capturedOpts?.publishPolicyMaxCacheAgeMs).toBeLessThanOrEqual(10_000); }); }); @@ -228,6 +232,29 @@ describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintex await g.ingestSwmHostModeEnvelope(cg, env, '12D3KooWSomeOtherRelayPeerNotThePublisher'); expect(await entriesFor(g, cg)).toBe(0); }); + + it('CIPHERTEXT envelope: never resolves publishPolicy (lazy confirmedPublic — no per-message eth_call on the curated path)', async () => { + // Branimir review #1239 follow-on: `confirmedPublic` only gates anything when + // `!isCiphertext`, so it must NOT be computed for the dominant ciphertext + // (curated) path — else the bulk of host-mode traffic pays a synchronous + // chain read it then discards. A ciphertext envelope must flow to the + // authority check WITHOUT any getContextGraphOnChainPolicy (eth_call). + const g = (await makeHostCore()) as unknown as IngestInternals; + const cg = 'cg-ingest-ciphertext'; + let policyCalls = 0; + g.getContextGraphOnChainPolicy = async () => { policyCalls += 1; return { accessPolicy: 0, publishPolicy: 1 }; }; + // A real ciphertext payload (passes the `isCiphertext` sniff) in a signed envelope. + const ciphertext = encodeEncryptedWorkspacePayload({ + version: '1', type: ENCRYPTED_WORKSPACE_ENVELOPE_TYPE, contextGraphId: cg, + senderIdentity: 'sender', operationId: 'op', shareOperationId: `op-ct-${cg}`, + timestampMs: 1_700_000_000_000, cipherAlgorithm: 'aes-256-gcm', + nonce: new Uint8Array(12), ciphertext: new Uint8Array([1, 2, 3]), + recipients: [], keyAgreementAlgorithm: 'x25519', ephemeralPublicKey: new Uint8Array(32), + }); + const env = await g.encodeWorkspaceGossipMessage(cg, ciphertext); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + expect(policyCalls).toBe(0); + }); }); /** From dfe15c0430094e9f40a81204eccd564629c2a324 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 16:44:27 +0200 Subject: [PATCH 10/12] fix(#1124): open-publish self-signed admission survives a stale allowlist + enforce policy in the verifier (otReviewAgent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two coupled findings on verifyHostModeEnvelopeAuthority: 🔴 The self-signed public exception only fired when getContextGraphAgentGateAddresses() returned null. A public-readable CG flipped on-chain to publishPolicy=1 (open publish) WITHOUT clearing its old participantAgents has a non-null (stale) allowlist, so the verifier fell into the curated branch and dropped valid open publishers — host-mode ACK quorum stayed unreachable for that valid open state, even though the contract's isAuthorizedPublisher ignores participants for open publish. 🟡 The exception was a trusted boolean (allowSelfSignedForPublicCg) decoupled from the security precondition (the forced-fresh both-axes check lived in the agent, enforced only by comments) — any caller could set it and get self-signed acceptance. Fix both: replace the boolean with `resolveOpenPublishPolicy` — the caller injects the on-chain policy resolver and the VERIFIER itself enforces accessPolicy===0 && publishPolicy===1 (fail-closed on undefined/throw), then takes the self-signed path INDEPENDENTLY of agentGateAddresses. The agent passes the same forced-fresh, ~5s-window resolver, and only for non-ciphertext (the lazy curated path still pays no chain read; the resolver shares the publishPolicy cache window so it's a warm hit, never a 2nd RPC). Tests (workspace-handler-host-mode-authority): open-publish + stale allowlist → accepted; same non-allowlisted signer with publishPolicy!=1 → curated reject (SIG_VERIFY_FAILED); thrown resolver → exception NOT granted (fail-closed). 163 agent + 77 publisher tests green. Co-Authored-By: Claude Opus 4.8 --- packages/agent/src/dkg-agent-swm-host.ts | 44 +++++++----- packages/publisher/src/workspace-handler.ts | 70 +++++++++++++------ ...kspace-handler-host-mode-authority.test.ts | 57 +++++++++++++++ 3 files changed, 135 insertions(+), 36 deletions(-) diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index d1fae69e9..936748533 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -1067,31 +1067,43 @@ export class SwmHostModeMethods extends DKGAgentBase { // for both the plaintext gate and the authority check. UNKNOWN CGs stay on // the drop path (safe; member catchup heals once the policy resolves). // - // LAZY by design (Branimir review #1239 follow-on): `confirmedPublic` only - // ever GATES anything when `!isCiphertext` — line 1059 below reads it only - // then, and `verifyHostModeEnvelopeAuthority` ignores - // `allowSelfSignedForPublicCg` whenever an allowlist exists. So short-circuit - // on `!isCiphertext` to skip the (now chain-backed) policy resolution - // entirely on the dominant CIPHERTEXT/curated path — otherwise the bulk of - // host-mode traffic would pay a synchronous eth_call to compute a value it - // then discards. Security-preserving: a ciphertext envelope on a public CG - // just stays in the curated authority path / opaque append and heals via - // catchup. + // LAZY by design (Branimir review #1239 follow-on): the self-signed public + // exception only matters for `!isCiphertext` traffic. So short-circuit on + // `!isCiphertext` to skip the (now chain-backed) policy resolution entirely + // on the dominant CIPHERTEXT/curated path — otherwise the bulk of host-mode + // traffic would pay a synchronous eth_call to compute a value it discards. + // Security-preserving: a ciphertext envelope on a public CG just stays in the + // curated authority path / opaque append and heals via catchup. const confirmedPublic = !isCiphertext && await this.isConfirmedPublicForHostMode(storageCgId); if (!isCiphertext && !confirmedPublic) return; // Authority check. Curated traffic verifies the envelope signature against - // the CG's agent allowlist. A confirmed-public CG has no allowlist, so pass - // `allowSelfSignedForPublicCg`: the SHARED verifier then validates signature - // + timestamp-freshness (the replay/eviction guard) AND binds the inner - // request to THIS CG — same envelope validation as curated, only the - // allowlist decision diverges (see SharedMemoryHandler.verifyHostModeEnvelopeAuthority). + // the CG's agent allowlist. For a self-publishable (open) CG, inject the + // on-chain policy RESOLVER (not a pre-decided flag): the SHARED verifier + // re-checks accessPolicy===0 && publishPolicy===1 itself, then validates the + // signature + timestamp-freshness AND binds the inner request to THIS CG — + // same envelope validation as curated, only the allowlist decision diverges + // (see SharedMemoryHandler.verifyHostModeEnvelopeAuthority). // // Use `storageCgId` (cleartext from the envelope) so the meta-graph + // chain-fallback resolvers work on the canonical id shape. const handler = this.getOrCreateSharedMemoryHandler(); const verdict = await handler.verifyHostModeEnvelopeAuthority( - data, storageCgId, fromPeerId, { allowSelfSignedForPublicCg: confirmedPublic }, + data, storageCgId, fromPeerId, + // Inject the on-chain policy RESOLVER (not a pre-decided flag) so the + // verifier enforces accessPolicy===0 && publishPolicy===1 itself and can + // take the self-signed path even when a STALE participant allowlist + // survives an open-publish flip. Lazy: pass it only for non-ciphertext, + // so the dominant ciphertext/curated path pays no chain read (the resolver + // shares the same ~5s publishPolicy cache window as the confirmedPublic + // resolution above, so this is at most a warm cache hit, never a 2nd RPC). + isCiphertext + ? undefined + : { + resolveOpenPublishPolicy: () => this.getContextGraphOnChainPolicy( + storageCgId, { publishPolicyMaxCacheAgeMs: HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS }, + ), + }, ); if (!verdict.accepted) { // 'no agent allowlist' on a NON-public CG is the expected brief chain-event diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index d54ee9f6e..8f9e9f714 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -1331,15 +1331,24 @@ export class SharedMemoryHandler { fromPeerId: string, options?: { /** - * GH #1124 — the caller (host-mode ingest) has positively confirmed this - * CG is PUBLIC (open, no curated agent allowlist). When set, a CG with no - * agent gate is accepted as long as the envelope passes the SAME shared - * verifier as curated traffic (signature + timestamp-freshness — NOT a - * bespoke check), with the claimed signer as its own one-entry allowlist - * (self-consistency), AND the inner request targets THIS CG. When unset, - * a no-allowlist CG is rejected defensively (curated mid-race / unknown). + * GH #1124 — resolver the host-mode ingest caller injects to prove this CG + * is FULLY OPEN (`accessPolicy === 0` AND `publishPolicy === 1`). This + * verifier CALLS it and enforces BOTH axes itself, so the self-signed + * exception cannot be taken by merely passing a flag — it requires the + * caller's actual (forced-fresh, fail-closed) on-chain policy. Any + * undefined/throw → treated as NOT open (fail-closed). When the policy + * resolves open, the self-signed public path is taken REGARDLESS of + * whether a (possibly STALE) participant/agent allowlist still exists on + * the CG, because an open-publish CG admits ANY authorized publisher — the + * contract's `isAuthorizedPublisher` ignores `participantAgents` for open + * publish, so a CG flipped to open publish without clearing its old + * allowlist must NOT fall into the curated branch and drop valid open + * publishers (otReviewAgent #1239). When unset, only curated/allowlisted + * traffic is accepted (a no-allowlist CG is dropped defensively). Callers + * SHOULD skip passing this for obviously-ciphertext envelopes so the + * dominant curated path pays no chain read. */ - allowSelfSignedForPublicCg?: boolean; + resolveOpenPublishPolicy?: () => Promise<{ accessPolicy?: number; publishPolicy?: number }>; }, ): Promise { const ctx = createOperationContext('share'); @@ -1356,19 +1365,32 @@ export class SharedMemoryHandler { } const agentGateAddresses = await this.getContextGraphAgentGateAddresses(contextGraphId); const allowedPeers = await this.getContextGraphAllowedPeers(contextGraphId); - if (agentGateAddresses === null) { - // GH #1124 — public (open) CGs have no curated agent allowlist. When the - // caller confirmed the CG is public, accept a self-signed envelope, but - // verify it through the SAME `verifyAgentEnvelope` the curated path uses — - // so the signature AND the 5-minute timestamp-freshness window (replay / - // store-eviction guard) are enforced identically; only the allowlist set - // diverges (the claimed signer is its own one-entry allowlist). - if (!options?.allowSelfSignedForPublicCg) { - // Not confirmed public → curated-mid-race or unknown. Drop defensively. - // (The host-mode ingest caller keys its transient-race log off - // `reasonCode === 'NO_AGENT_ALLOWLIST'` — keep the code stable.) - return { accepted: false, reasonCode: 'NO_AGENT_ALLOWLIST', reason: 'no agent allowlist on context graph' }; + + // GH #1124 — resolve "fully-open (self-publishable) CG" HERE rather than + // trusting a caller flag: the caller injects the (forced-fresh, fail-closed) + // on-chain policy resolver and THIS verifier enforces BOTH axes + // (accessPolicy === 0 AND publishPolicy === 1). Any undefined/throw → not + // open (fail-closed). Gated INDEPENDENTLY of `agentGateAddresses`: an + // open-publish CG admits ANY authorized publisher (the contract's + // `isAuthorizedPublisher` ignores `participantAgents` for open publish), so a + // CG that retains a STALE participant allowlist after being flipped open must + // NOT fall into the curated branch and drop valid open publishers. + let confirmedOpenPublish = false; + if (options?.resolveOpenPublishPolicy) { + try { + const { accessPolicy, publishPolicy } = await options.resolveOpenPublishPolicy(); + confirmedOpenPublish = accessPolicy === 0 && publishPolicy === 1; + } catch { + confirmedOpenPublish = false; } + } + + if (confirmedOpenPublish) { + // Self-signed public path — verify through the SAME `verifyAgentEnvelope` + // the curated path uses (signature + 5-minute timestamp-freshness window: + // replay / store-eviction guard), with the claimed signer as its own + // one-entry allowlist (self-consistency). Accepted regardless of any + // (stale) participant gate, per the open-publish rationale above. if (!envelope.agentAddress || !ethers.isAddress(envelope.agentAddress)) { return { accepted: false, reasonCode: 'SIG_VERIFY_FAILED', reason: 'public-CG envelope missing a valid signer address' }; } @@ -1403,6 +1425,14 @@ export class SharedMemoryHandler { } return { accepted: true }; } + + if (agentGateAddresses === null) { + // Not confirmed open-publish AND no curated allowlist → curated mid-race or + // unknown → drop defensively. (The host-mode ingest caller keys its + // transient-race log off `reasonCode === 'NO_AGENT_ALLOWLIST'` — keep the + // code stable.) + return { accepted: false, reasonCode: 'NO_AGENT_ALLOWLIST', reason: 'no agent allowlist on context graph' }; + } if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) { return { accepted: false, reasonCode: 'PEER_NOT_IN_ALLOWLIST', reason: `peer ${fromPeerId} not in peer allowlist` }; } diff --git a/packages/publisher/test/workspace-handler-host-mode-authority.test.ts b/packages/publisher/test/workspace-handler-host-mode-authority.test.ts index 49d7fc5b8..6739ad4b4 100644 --- a/packages/publisher/test/workspace-handler-host-mode-authority.test.ts +++ b/packages/publisher/test/workspace-handler-host-mode-authority.test.ts @@ -405,4 +405,61 @@ describe('SharedMemoryHandler.verifyHostModeEnvelopeAuthority (LU-6 host-mode ga } }); }); + + // GH #1124 (otReviewAgent #1239) — the self-signed OPEN-PUBLISH exception. + // The exception is gated on the caller-injected on-chain policy RESOLVER + // (accessPolicy===0 && publishPolicy===1), enforced HERE — NOT on a trusted + // boolean, and NOT on `agentGateAddresses === null`. So an open-publish CG + // that still carries a STALE participant allowlist must take the self-signed + // path (the contract's isAuthorizedPublisher ignores participants for open + // publish), instead of falling into the curated branch and dropping valid + // open publishers. + describe('open-publish self-signed path (resolveOpenPublishPolicy)', () => { + it('accepts a self-signed open-publish envelope EVEN WHEN a stale agent allowlist survives the open flip', async () => { + const staleParticipant = ethers.Wallet.createRandom(); + await insertAgentGate(DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT, staleParticipant.address); + const openPublisher = ethers.Wallet.createRandom(); // NOT in the stale allowlist + + // PLAINTEXT signed envelope (public SWM), publisherPeerId === sender. + const wire = await signWorkspaceMessage(openPublisher, workspaceMessage('Open Publish', 'op-open-stale-allowlist')); + const handler = makeHandler(); + + const verdict = await handler.verifyHostModeEnvelopeAuthority(wire, CONTEXT_GRAPH_ID, PUBLISHER_PEER_ID, { + resolveOpenPublishPolicy: async () => ({ accessPolicy: 0, publishPolicy: 1 }), + }); + expect(verdict.accepted).toBe(true); + }); + + it('still REJECTS the same non-allowlisted signer when the CG is NOT open publish (publishPolicy != 1 → curated branch)', async () => { + const staleParticipant = ethers.Wallet.createRandom(); + await insertAgentGate(DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT, staleParticipant.address); + const nonMember = ethers.Wallet.createRandom(); + + const wire = await signWorkspaceMessage(nonMember, workspaceMessage('Curated', 'op-curated-nonmember')); + const handler = makeHandler(); + + // public READ but curated PUBLISH → not self-publishable → curated branch. + const verdict = await handler.verifyHostModeEnvelopeAuthority(wire, CONTEXT_GRAPH_ID, PUBLISHER_PEER_ID, { + resolveOpenPublishPolicy: async () => ({ accessPolicy: 0, publishPolicy: 0 }), + }); + expect(verdict.accepted).toBe(false); + if (!verdict.accepted) expect(verdict.reasonCode).toBe('SIG_VERIFY_FAILED'); + }); + + it('fail-closed: a thrown policy resolver does not grant the self-signed exception', async () => { + const staleParticipant = ethers.Wallet.createRandom(); + await insertAgentGate(DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT, staleParticipant.address); + const openPublisher = ethers.Wallet.createRandom(); + + const wire = await signWorkspaceMessage(openPublisher, workspaceMessage('Throws', 'op-resolver-throws')); + const handler = makeHandler(); + + const verdict = await handler.verifyHostModeEnvelopeAuthority(wire, CONTEXT_GRAPH_ID, PUBLISHER_PEER_ID, { + resolveOpenPublishPolicy: async () => { throw new Error('rpc down'); }, + }); + // Resolver threw → not confirmed open → curated branch → signer not in the + // (stale) allowlist → rejected. The exception is never granted on failure. + expect(verdict.accepted).toBe(false); + }); + }); }); From 95ce067bec9174f466f8d86c8672e7b9cb14b65e Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 17:22:53 +0200 Subject: [PATCH 11/12] fix(#1124): verify public envelopes on host-catchup replay + wire identity gate in quorum test (otReviewAgent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🔴 Public-CG host-catchup forgery. Now that public plaintext is host-mode-stored and served via catchup, the catchup-apply path (handle with trustedReplay) reached a member from an UNTRUSTED relaying host with the publisherPeerId===fromPeerId transport bind skipped — and for a public CG (no agent gate) handle() applied the plaintext with ZERO signature verification. A malicious host could fabricate brand-new public bytes (never seen by any member's live ingest gate) and have members apply them. Fix (workspace-handler.ts handle): add a public-CG self-signed authority gate that fires ONLY on trustedReplay — require a valid envelope.agentAddress and verifyAgentEnvelope against the claimed signer as its own one-entry allowlist (skipTimestampFreshness, since catchup replays aged envelopes; the signature + verifyAgentEnvelope's envelope.contextGraphId===contextGraphId bind still hold). Scoped to trustedReplay so the LIVE public path is unchanged — it still accepts the legacy unsigned-public producer, bound by the live publisherPeerId=== fromPeerId transport check. (Reviewed design caught that an unconditional gate would silently fail-close legitimate unsigned-public live writes + break an existing test.) Residual (documented, inherent to open-publish): publisherPeerId OWNERSHIP attribution is not cryptographically authenticatable on catchup for an open-publish CG (libp2p peerId vs EVM agentAddress, no cross-binding). Bounded: attacker needs a valid agent key; open publish lets anyone write anyway; on-chain finalization is the system of record. 🟡 Quorum test wired the production identity gate. host-mode-quorum-bridge: the collector previously ran with identity verification OFF, so it reached quorum on mere signature shape. Now verifyIdentity is wired against the generated (identityId→signer) registration, proving the ACKs are on-chain-submittable; a load-bearing negative shows unregistered signers are rejected. Tests: catchup signed→applied, unsigned→rejected, claimed!=recovered→rejected, LIVE unsigned→still applies (regression guard). 82 publisher + 136 agent green. Co-Authored-By: Claude Opus 4.8 --- packages/publisher/src/workspace-handler.ts | 36 +++++++++++++ .../test/host-mode-quorum-bridge-1124.test.ts | 51 +++++++++++++++++-- .../test/workspace-handler-agent-gate.test.ts | 44 ++++++++++++++++ 3 files changed, 127 insertions(+), 4 deletions(-) diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index 8f9e9f714..a4bc1a269 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -861,6 +861,42 @@ export class SharedMemoryHandler { // become good on retry. return { applied: false, reason: 'agent envelope verification failed', retryable: false }; } + } else if (trustedReplay && !hasPrivateAccessPolicy && !decoded.senderKeyMessage && !decoded.encryptedPayload) { + // GH #1124 (otReviewAgent #1239) — PUBLIC-CG host-catchup forgery gate. + // A public CG has no agent allowlist, so the curated branch above is + // skipped and a plaintext public envelope was previously applied with + // ZERO signature verification. That is acceptable on the LIVE gossip path + // (sender == publisher, bound by `publisherPeerId === fromPeerId` at the + // guard further below), but NOT on the `trustedReplay` host-catchup path: + // there the sender is an UNTRUSTED relaying host, the transport bind is + // skipped, and a malicious host can FABRICATE brand-new public bytes that + // never traversed any member's live ingest gate. So when replaying, the + // envelope MUST be self-signed and that signature MUST verify — otherwise + // a forged / unsigned / tampered public envelope would be applied. + // + // Self-consistency: the claimed signer is its own one-entry allowlist — + // a pure crypto check (no chain read). The signed payload + // (computeGossipSigningPayload over {type, contextGraphId, timestamp, + // encoded WorkspacePublishRequest}) authenticates the inner request + // (contextGraphId / publisherPeerId / nquads) as a unit, and + // verifyAgentEnvelope's `envelope.contextGraphId === contextGraphId` + // check is the cross-CG bind. Freshness is skipped (aged catchup), but + // the signature still must hold. Scoped to `trustedReplay` so the LIVE + // public path — including the legacy unsigned-public producer — is + // unchanged. (publisherPeerId OWNERSHIP attribution is NOT recoverable on + // catchup for an open-publish CG — see the residual note on the PR.) + if (!envelope || !envelope.agentAddress || !ethers.isAddress(envelope.agentAddress)) { + const reason = `public context graph "${contextGraphId}" host-catchup envelope is unsigned — refusing to replay`; + this.log.warn(ctx, `SWM write rejected: ${reason}`); + return { applied: false, reason, retryable: false }; + } + const selfConsistent = await this.verifyAgentEnvelope( + envelope, signedPayload, contextGraphId, [ethers.getAddress(envelope.agentAddress)], ctx, + { requireLocalMembership: false, skipTimestampFreshness: true }, + ); + if (!selfConsistent) { + return { applied: false, reason: 'public-CG host-catchup envelope failed signature verification', retryable: false }; + } } const requiresEncryptedPayload = hasPrivateAccessPolicy || agentGateAddresses !== null; diff --git a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts index b12551f85..39cb8d1c1 100644 --- a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts +++ b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts @@ -98,8 +98,18 @@ async function makeNonMemberCores(count: number, seeded: boolean) { return { wallets, handlers }; } -function makeCollector(handlers: StorageACKHandler[]) { +// Wire the collector with the PRODUCTION identity gate. Each non-member host's +// signer wallet (wallets[i]) is "registered" on-chain to its node identity +// (i+1) via this map, so `verifyIdentity` enforces the SAME check production +// applies: an ACK is quorum-eligible only if its recovered signer is the +// registered operational key for the claimed identity. Without this the +// collector would skip identity verification entirely and accept any +// syntactically-valid signature (otReviewAgent #1239) — this proves the ACKs +// are genuinely on-chain-submittable, not just well-formed. +function makeCollector(handlers: StorageACKHandler[], wallets: ethers.Wallet[]) { const peers = handlers.map((_, i) => `host-${i}`); + const registered = new Map(); // identityId → registered signer address + wallets.forEach((w, i) => registered.set(String(i + 1), w.address.toLowerCase())); const deps: ACKCollectorDeps = { gossipPublish: async () => {}, sendP2P: async (peerId, _protocol, data) => { @@ -107,6 +117,8 @@ function makeCollector(handlers: StorageACKHandler[]) { return handlers[idx].handler(data, { toString: () => peerId }); }, getConnectedCorePeers: () => peers, + verifyIdentity: async (recoveredAddress: string, identityId: bigint) => + registered.get(identityId.toString()) === recoveredAddress.toLowerCase(), log: () => {}, }; return new ACKCollector(deps); @@ -127,13 +139,16 @@ const collectArgs = { }; describe('#1124 end-state: public-CG quorum is REACHED purely via non-member host-mode cores', () => { - it('POST-FIX — 3 non-member hosts holding the host-mode-ingested plaintext reach quorum with valid signed ACKs', async () => { + it('POST-FIX — 3 non-member hosts holding the host-mode-ingested plaintext reach quorum with valid, IDENTITY-VERIFIED signed ACKs', async () => { const { wallets, handlers } = await makeNonMemberCores(3, /* seeded */ true); - const collector = makeCollector(handlers); + // verifyIdentity wired with the (identity→signer) registration → the + // collector applies the SAME on-chain identity gate production does. + const collector = makeCollector(handlers, wallets); const result = await collector.collect({ ...collectArgs }); - // Quorum (DEFAULT_REQUIRED_ACKS = 3) reached entirely from non-members. + // Quorum (DEFAULT_REQUIRED_ACKS = 3) reached entirely from non-members, and + // every ACK passed the identity gate (registered signer for its identity). expect(result.acks).toHaveLength(3); // Every ACK is a real EIP-191 signature over the canonical V10 publish @@ -154,6 +169,34 @@ describe('#1124 end-state: public-CG quorum is REACHED purely via non-member hos } }); + it('the identity gate is load-bearing — UNREGISTERED host signers are rejected (cannot reach quorum)', async () => { + // Proves the wired verifyIdentity actually gates: if the signers are NOT the + // registered operational keys for their identities, the collector rejects + // every ACK and quorum is unreachable — i.e. the POST-FIX quorum above is + // contingent on real registration, not just signature shape. Asserted at the + // handler+verifier layer (no full collect()) to avoid the ~31s quorum-fail + // retry budget. + const { wallets, handlers } = await makeNonMemberCores(3, /* seeded */ true); + // Registration map deliberately EMPTY → no signer is registered for any id. + const verifyIdentity = async (_addr: string, _id: bigint) => false; + const intent = encodePublishIntent({ + merkleRoot, contextGraphId, publisherPeerId: 'publisher-edge', + publicByteSize: Number(publicByteSize), isPrivate: false, kaCount: 1, + rootEntities: [], merkleLeafCount, + }); + for (let i = 0; i < handlers.length; i++) { + // The host still SIGNS a structurally-valid ACK... + const ack = decodeStorageACK(await handlers[i].handler(intent, { toString: () => `host-${i}` })); + expect(isStorageACKDecline(ack)).toBe(false); + // ...but the production identity gate the collector applies rejects it. + const recovered = ethers.recoverAddress(ethers.hashMessage(computePublishACKDigest( + TEST_CHAIN_ID, TEST_KAV10_ADDR, cgIdBigInt, merkleRoot, 1n, publicByteSize, 1n, 0n, BigInt(merkleLeafCount), + )), { r: ethers.hexlify(ack.coreNodeSignatureR), yParityAndS: ethers.hexlify(ack.coreNodeSignatureVS) }); + expect(recovered.toLowerCase()).toBe(wallets[i].address.toLowerCase()); // sig is genuine + expect(await verifyIdentity(recovered, BigInt(i + 1))).toBe(false); // but unregistered → rejected + } + }); + it('PRE-FIX (negative control) — with the plaintext DROPPED (empty SWM) every host DECLINEs NO_DATA, the quorum-blocking signal', async () => { // This is the #1124 failure mode: the gate dropped the self-signed public // plaintext, SWM stayed empty. Each host then returns NO_DATA_IN_SWM — a diff --git a/packages/publisher/test/workspace-handler-agent-gate.test.ts b/packages/publisher/test/workspace-handler-agent-gate.test.ts index df82bfd4f..cc67f02dd 100644 --- a/packages/publisher/test/workspace-handler-agent-gate.test.ts +++ b/packages/publisher/test/workspace-handler-agent-gate.test.ts @@ -170,6 +170,50 @@ describe('SharedMemoryHandler agent-gated gossip', () => { expect(workspaceOwned.get(CONTEXT_GRAPH_ID)?.get(ENTITY)).toBe(PEER_ID); }); + // GH #1124 (otReviewAgent #1239) — PUBLIC-CG host-catchup forgery gate. + // Public plaintext is now host-mode-stored + served via catchup; the + // catchup-apply path (`trustedReplay`) reaches a member from an UNTRUSTED + // relaying host with the `publisherPeerId === fromPeerId` transport bind + // skipped. So on `trustedReplay` the public envelope MUST be self-signed and + // verify — while the LIVE path stays unchanged (legacy unsigned-public + // producer, bound by the transport check). + describe('public-CG host-catchup forgery gate (trustedReplay)', () => { + it('LIVE (no trustedReplay): unsigned raw public gossip still applies (legacy producer — unchanged)', async () => { + const outcome = await handler.handle(workspaceMessage('Live Unsigned', 'ws-live-unsigned'), PEER_ID); + expect(outcome.applied).toBe(true); + await expectStoredName('Live Unsigned'); + }); + + it('CATCHUP: a genuinely self-signed public envelope applies', async () => { + const signer = ethers.Wallet.createRandom(); + const wire = await signWorkspaceMessage(signer, workspaceMessage('Catchup Signed', 'ws-catchup-signed')); + const outcome = await handler.handle(wire, 'host-relay-peer', undefined, { trustedReplay: true }); + expect(outcome.applied).toBe(true); + await expectStoredName('Catchup Signed'); + }); + + it('CATCHUP: an UNSIGNED fabricated public envelope is REJECTED (was applied pre-fix)', async () => { + // A malicious host fabricates raw unsigned bytes that never went through + // any member's live ingest gate, served only to handle({trustedReplay}). + const outcome = await handler.handle( + workspaceMessage('Forged Unsigned', 'ws-catchup-unsigned'), 'host-relay-peer', undefined, { trustedReplay: true }, + ); + expect(outcome.applied).toBe(false); + await expectWorkspaceEmpty(); + }); + + it('CATCHUP: a public envelope whose claimed signer != recovered signer is REJECTED', async () => { + const realSigner = ethers.Wallet.createRandom(); + const claimed = ethers.Wallet.createRandom(); + // Signed by realSigner but the envelope claims `claimed`'s address — the + // self-consistency check ([claimed] as the one-entry allowlist) fails. + const wire = await signWorkspaceMessage(realSigner, workspaceMessage('Spoofed Signer', 'ws-catchup-spoof'), claimed.address); + const outcome = await handler.handle(wire, 'host-relay-peer', undefined, { trustedReplay: true }); + expect(outcome.applied).toBe(false); + await expectWorkspaceEmpty(); + }); + }); + it.each([ ['_meta', META_GRAPH], ['ontology', ONTOLOGY_GRAPH], From 56d7b38205fec067a7bd19df7d7b5e9b23af7070 Mon Sep 17 00:00:00 2001 From: Bojan Date: Fri, 19 Jun 2026 17:39:41 +0200 Subject: [PATCH 12/12] fix(#1124): reattach SharedMemoryApplyOutcome JSDoc + drive identity gate through the real collector (otReviewAgent) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 🟡 The HostModeRejectionCode / HostModeEnvelopeAuthorityVerdict types were inserted between SharedMemoryApplyOutcome's JSDoc and the type, orphaning the contract doc. Moved them below SharedMemoryApplyOutcome so each exported type keeps its own documentation. 🟡 The quorum identity-gate negative control defined a local verifyIdentity and called it directly, so it didn't actually exercise ACKCollector's gate (would pass even if collect() stopped calling verifyIdentity). Rewrote it to drive the REAL ACKCollector wired with verifyIdentity rejecting every signer, asserting collect() fails to reach quorum. Identity rejection is non-retryable, so it fails fast (87ms, no ~31s transient-decline budget). 66 publisher tests green. Co-Authored-By: Claude Opus 4.8 --- packages/publisher/src/workspace-handler.ts | 38 +++++++-------- .../test/host-mode-quorum-bridge-1124.test.ts | 46 ++++++++----------- 2 files changed, 39 insertions(+), 45 deletions(-) diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index a4bc1a269..46ebb7697 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -103,25 +103,6 @@ export interface ContextGraphMetaOracleRecord { * MUST fall back to NOT emitting an ack (silent best-effort, same * as pre-PR-D gossip behaviour). */ -/** - * Structured rejection code for {@link SharedMemoryHandler.verifyHostModeEnvelopeAuthority}. - * Callers (e.g. the host-mode ingest path's #1124 public-CG exception) key off - * this stable code rather than the free-form `reason` text, so a wording change - * to a log message can never silently flip a behavioral branch. - */ -export type HostModeRejectionCode = - | 'DECODE_FAILED' - | 'UNSIGNED' - | 'NO_AGENT_ALLOWLIST' - | 'PEER_NOT_IN_ALLOWLIST' - | 'SIG_VERIFY_FAILED' - | 'CG_MISMATCH' - | 'PUBLISHER_PEER_MISMATCH'; - -export type HostModeEnvelopeAuthorityVerdict = - | { accepted: true } - | { accepted: false; reasonCode: HostModeRejectionCode; reason: string }; - export type SharedMemoryApplyOutcome = | { applied: true; @@ -159,6 +140,25 @@ export type SharedMemoryApplyOutcome = } | { applied: false; reason: string; retryable: boolean }; +/** + * Structured rejection code for {@link SharedMemoryHandler.verifyHostModeEnvelopeAuthority}. + * Callers (e.g. the host-mode ingest path's #1124 public-CG exception) key off + * this stable code rather than the free-form `reason` text, so a wording change + * to a log message can never silently flip a behavioral branch. + */ +export type HostModeRejectionCode = + | 'DECODE_FAILED' + | 'UNSIGNED' + | 'NO_AGENT_ALLOWLIST' + | 'PEER_NOT_IN_ALLOWLIST' + | 'SIG_VERIFY_FAILED' + | 'CG_MISMATCH' + | 'PUBLISHER_PEER_MISMATCH'; + +export type HostModeEnvelopeAuthorityVerdict = + | { accepted: true } + | { accepted: false; reasonCode: HostModeRejectionCode; reason: string }; + /** * Unambiguous composite key for `seenShareOps`. * diff --git a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts index 39cb8d1c1..262cb83b9 100644 --- a/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts +++ b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts @@ -169,32 +169,26 @@ describe('#1124 end-state: public-CG quorum is REACHED purely via non-member hos } }); - it('the identity gate is load-bearing — UNREGISTERED host signers are rejected (cannot reach quorum)', async () => { - // Proves the wired verifyIdentity actually gates: if the signers are NOT the - // registered operational keys for their identities, the collector rejects - // every ACK and quorum is unreachable — i.e. the POST-FIX quorum above is - // contingent on real registration, not just signature shape. Asserted at the - // handler+verifier layer (no full collect()) to avoid the ~31s quorum-fail - // retry budget. - const { wallets, handlers } = await makeNonMemberCores(3, /* seeded */ true); - // Registration map deliberately EMPTY → no signer is registered for any id. - const verifyIdentity = async (_addr: string, _id: bigint) => false; - const intent = encodePublishIntent({ - merkleRoot, contextGraphId, publisherPeerId: 'publisher-edge', - publicByteSize: Number(publicByteSize), isPrivate: false, kaCount: 1, - rootEntities: [], merkleLeafCount, - }); - for (let i = 0; i < handlers.length; i++) { - // The host still SIGNS a structurally-valid ACK... - const ack = decodeStorageACK(await handlers[i].handler(intent, { toString: () => `host-${i}` })); - expect(isStorageACKDecline(ack)).toBe(false); - // ...but the production identity gate the collector applies rejects it. - const recovered = ethers.recoverAddress(ethers.hashMessage(computePublishACKDigest( - TEST_CHAIN_ID, TEST_KAV10_ADDR, cgIdBigInt, merkleRoot, 1n, publicByteSize, 1n, 0n, BigInt(merkleLeafCount), - )), { r: ethers.hexlify(ack.coreNodeSignatureR), yParityAndS: ethers.hexlify(ack.coreNodeSignatureVS) }); - expect(recovered.toLowerCase()).toBe(wallets[i].address.toLowerCase()); // sig is genuine - expect(await verifyIdentity(recovered, BigInt(i + 1))).toBe(false); // but unregistered → rejected - } + it('the identity gate is load-bearing — the REAL collector cannot reach quorum when no host signer is a registered identity', async () => { + // Drives the ACTUAL ACKCollector with verifyIdentity rejecting every signer + // (no registration), so the test fails if the collector ever stopped calling + // verifyIdentity or ignored its result. The hosts still SIGN valid ACKs, but + // identity rejection is non-retryable (not a transient decline), so the + // collector settles all peers and fails quorum fast — no ~31s retry budget. + const { handlers } = await makeNonMemberCores(3, /* seeded */ true); + const peers = handlers.map((_, i) => `host-${i}`); + const deps: ACKCollectorDeps = { + gossipPublish: async () => {}, + sendP2P: async (peerId, _protocol, data) => { + const idx = parseInt(peerId.replace('host-', ''), 10); + return handlers[idx].handler(data, { toString: () => peerId }); + }, + getConnectedCorePeers: () => peers, + verifyIdentity: async () => false, // no signer is a registered identity + log: () => {}, + }; + const collector = new ACKCollector(deps); + await expect(collector.collect({ ...collectArgs })).rejects.toThrow(); }); it('PRE-FIX (negative control) — with the plaintext DROPPED (empty SWM) every host DECLINEs NO_DATA, the quorum-blocking signal', async () => {