diff --git a/packages/agent/src/dkg-agent-cg-registry.ts b/packages/agent/src/dkg-agent-cg-registry.ts index 5409dedda..d52e49895 100644 --- a/packages/agent/src/dkg-agent-cg-registry.ts +++ b/packages/agent/src/dkg-agent-cg-registry.ts @@ -474,7 +474,26 @@ 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?: { + /** + * 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.) + */ + publishPolicyMaxCacheAgeMs?: number; + }): Promise<{ accessPolicy?: number; publishPolicy?: number; }> { @@ -492,7 +511,12 @@ export class ContextGraphRegistryMethods extends DKGAgentBase { const isPublishPolicyCacheFresh = (key: string): boolean => { 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-crypto.ts b/packages/agent/src/dkg-agent-crypto.ts index 6dfac85cb..9fdb89b5e 100644 --- a/packages/agent/src/dkg-agent-crypto.ts +++ b/packages/agent/src/dkg-agent-crypto.ts @@ -386,6 +386,14 @@ 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 }; if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) { return signingRecord; diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index be44828d3..936748533 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)); @@ -678,6 +688,48 @@ export class SwmHostModeMethods extends DKGAgentBase { } } + /** + * 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, 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 { + // `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 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, { publishPolicyMaxCacheAgeMs: HOST_MODE_PUBLISH_POLICY_MAX_CACHE_AGE_MS }, + ); + return accessPolicy === 0 && publishPolicy === 1; + } catch { + return false; + } + } + /** * Register the host-mode gossip handler for `contextGraphId` and * track its reference so {@link unwireSwmHostModeHandler} can @@ -1009,33 +1061,57 @@ export class SwmHostModeMethods extends DKGAgentBase { isCiphertext = skm.type === SWM_SENDER_KEY_MESSAGE_TYPE; } catch { /* fall through */ } } - if (!isCiphertext) 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. + // 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 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). // - // 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. + // 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. 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); + const verdict = await handler.verifyHostModeEnvelopeAuthority( + 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" 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. - const isTransientRace = verdict.reason === 'no agent allowlist on context graph'; + // '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( ctx, @@ -1112,6 +1188,64 @@ 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( 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..f4b9716ee 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,59 @@ describe('DKGAgent.getContextGraphOnChainPolicy', () => { expect(Date.now() - newTs).toBeLessThan(1_000); }); + // Branimir review on #1239 — the host-mode self-signed admission gate + // (`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: 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 }, + }); + 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 // 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/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 new file mode 100644 index 000000000..93b8773e1 --- /dev/null +++ b/packages/agent/test/swm/host-mode-public-ingest-1124.test.ts @@ -0,0 +1,423 @@ +/** + * 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. `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 { + encodeWorkspacePublishRequest, + encodePublishIntent, + encodeEncryptedWorkspacePayload, + ENCRYPTED_WORKSPACE_ENVELOPE_TYPE, + 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'; + +interface ClassifierInternals { + isConfirmedPublicForHostMode(cgId: string): Promise; + getContextGraphOnChainPolicy( + cgId: string, + options?: { publishPolicyMaxCacheAgeMs?: number }, + ): Promise<{ accessPolicy?: number; publishPolicy?: number }>; +} + +interface IngestInternals { + getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>; + 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 () => { + 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('open read + open publish (accessPolicy 0, publishPolicy 1) → public', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0, publishPolicy: 1 }); + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(true); + }); + + 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: 0, publishPolicy: 0 }); + expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false); + }); + + it('curated read (accessPolicy 1) → NOT public, regardless of publishPolicy', async () => { + const g = (await makeCore()) as unknown as ClassifierInternals; + 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); + }); + + 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); + }); + + 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: { publishPolicyMaxCacheAgeMs?: number } | undefined; + g.getContextGraphOnChainPolicy = async (_cg, opts) => { capturedOpts = opts; return { accessPolicy: 0, publishPolicy: 1 }; }; + await g.isConfirmedPublicForHostMode('cg'); + // A short window: positive, and far under the general 60s TTL. + expect(capturedOpts?.publishPolicyMaxCacheAgeMs).toBeGreaterThan(0); + expect(capturedOpts?.publishPolicyMaxCacheAgeMs).toBeLessThanOrEqual(10_000); + }); +}); + +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 }))); + }); + + 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, + }); + + async function entriesFor(g: IngestInternals, cg: string): Promise { + const stats = await g.getSwmHostModeStats(); + 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.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); + }); + + 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, publishPolicy: 0 }); + const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg)); + await g.ingestSwmHostModeEnvelope(cg, env, PEER); + expect(await entriesFor(g, cg)).toBe(0); + }); + + 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.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 (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, 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; + 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, 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); + 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); + }); + + 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); + }); +}); + +/** + * 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/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..0c55f4d2b --- /dev/null +++ b/packages/cli/test/issue-306-787-write-quad-validation.test.ts @@ -0,0 +1,77 @@ +/** + * 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. 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 { startLiveDaemon, stopLiveDaemon, postJson, type LiveDaemon } from './helpers/live-daemon.js'; + +let daemon: LiveDaemon | undefined; +const CG = 'wq-validation-cg'; + +beforeAll(async () => { + daemon = await startLiveDaemon({ authEnabled: true }); + const { status, body } = await postJson(daemon, '/api/context-graph/create', { + id: CG, name: 'WQ Validation CG', accessPolicy: 0, + }); + if (status >= 300) throw new Error(`CG create failed: ${status} ${JSON.stringify(body)}`); +}, 120_000); + +afterAll(async () => { + 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 { status } = await postJson(daemon!, '/api/shared-memory/write', { + contextGraphId: CG, quads: [' "v" .'], + }); + 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 { status, body } = await postJson(daemon!, '/api/shared-memory/write', { + contextGraphId: CG, quads: [{ subject: 'urn:wq:s787', predicate: 'http://schema.org/name', object: '"ok787"' }], + }); + 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 postJson(daemon!, '/api/knowledge-assets', { contextGraphId: CG, name: 'ka-306' }); + expect(created.status, 'KA create precondition').toBeLessThan(300); + const { status } = await postJson(daemon!, '/api/knowledge-assets/ka-306/wm/write', { + contextGraphId: CG, quads: [' .'], + }); + 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 postJson(daemon!, '/api/knowledge-assets', { contextGraphId: CG, name: 'ka-306-ok' }); + expect(created.status).toBeLessThan(300); + 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(status, JSON.stringify(body)).toBe(200); + }); +}); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index ccf100508..46ebb7697 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -140,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`. * @@ -842,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; @@ -1310,28 +1365,112 @@ export class SharedMemoryHandler { rawBytes: Uint8Array, contextGraphId: string, fromPeerId: string, - ): Promise<{ accepted: true } | { accepted: false; reason: string }> { + options?: { + /** + * 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. + */ + resolveOpenPublishPolicy?: () => Promise<{ accessPolicy?: number; publishPolicy?: number }>; + }, + ): 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; + const { envelope, signedPayload, request } = 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); + + // 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' }; + } + 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' }; + } + // 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 (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' }; + // 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, 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 +1481,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 }; } 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..262cb83b9 --- /dev/null +++ b/packages/publisher/test/host-mode-quorum-bridge-1124.test.ts @@ -0,0 +1,219 @@ +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 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. + * + * 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.) + * + * 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'; + +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) { + // 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 = { + 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 }; +} + +// 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) => { + const idx = parseInt(peerId.replace('host-', ''), 10); + 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); +} + +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, IDENTITY-VERIFIED signed ACKs', async () => { + const { wallets, handlers } = await makeNonMemberCores(3, /* seeded */ true); + // 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, 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 + // 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('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 () => { + // 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); + } + }); +}); 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], 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); + }); + }); });