From 66b017087393b8679b014db0bb464d3de76a6485 Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Tue, 5 May 2026 10:53:50 +0200 Subject: [PATCH 1/4] fix: authenticate agent-gated swm gossip --- packages/agent/src/dkg-agent.ts | 141 ++++++++++++-- packages/agent/test/agent-audit-extra.test.ts | 44 ++--- packages/core/src/proto/gossip-envelope.ts | 37 +++- packages/core/src/proto/index.ts | 3 + packages/core/test/v10-proto.test.ts | 10 +- .../core/test/v10-protocol-coverage.test.ts | 28 ++- packages/publisher/src/workspace-handler.ts | 168 ++++++++++++++++- packages/publisher/test/workspace.test.ts | 173 +++++++++++++++++- 8 files changed, 529 insertions(+), 75 deletions(-) diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 94b821ec9..7842010ec 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -10,6 +10,10 @@ import { computeACKDigest, encodePublishRequest, encodeKAUpdateRequest, + encodeGossipEnvelope, + computeGossipSigningPayload, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, encodeFinalizationMessage, type FinalizationMessageMsg, getGenesisQuads, computeNetworkId, SYSTEM_PARANETS, DKG_ONTOLOGY, Logger, createOperationContext, sparqlString, escapeSparqlLiteral, @@ -2861,6 +2865,118 @@ export class DKGAgent { return this._publish(contextGraphId, input as Quad[], undefined, thirdArg ?? fourthArg); } + private getDefaultWorkspaceGossipSigningAgent(): (AgentKeyRecord & { privateKey: string }) | null { + if (!this.defaultAgentAddress) return null; + const defaultAddress = this.defaultAgentAddress.toLowerCase(); + for (const record of this.localAgents.values()) { + if (record.agentAddress.toLowerCase() === defaultAddress && record.privateKey) { + return { ...record, privateKey: record.privateKey }; + } + } + return null; + } + + private async getContextGraphAgentGateAddresses(contextGraphId: string): Promise { + const seen = new Set(); + const agents: string[] = []; + let sawAgentGate = false; + const add = (value: string | undefined) => { + if (!value || !ethers.isAddress(value)) return; + const checksum = ethers.getAddress(value); + const key = checksum.toLowerCase(); + if (seen.has(key)) return; + seen.add(key); + agents.push(checksum); + }; + + const subscriptionAgents = this.subscribedContextGraphs.get(contextGraphId)?.participantAgents ?? []; + if (subscriptionAgents.length > 0) sawAgentGate = true; + for (const agentAddress of subscriptionAgents) { + add(agentAddress); + } + + const contextGraphUri = paranetDataGraphUri(contextGraphId); + const cgMetaGraph = paranetMetaGraphUri(contextGraphId); + const result = await this.store.query( + `SELECT ?agent WHERE { + GRAPH <${cgMetaGraph}> { + { <${contextGraphUri}> <${DKG_ONTOLOGY.DKG_ALLOWED_AGENT}> ?agent } + UNION + { <${contextGraphUri}> <${DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT}> ?agent } + } + }`, + ); + if (result.type === 'bindings') { + if (result.bindings.length > 0) sawAgentGate = true; + for (const row of result.bindings) { + const raw = row['agent']; + if (typeof raw === 'string') { + add(raw.replace(/^"/, '').replace(/"(@[a-zA-Z-]+|\^\^<[^>]+>)?$/, '')); + } + } + } + + return sawAgentGate ? agents : null; + } + + private async resolveWorkspaceGossipSigningAgent( + contextGraphId: string, + ): Promise<(AgentKeyRecord & { privateKey: string }) | null> { + const allowedAgents = await this.getContextGraphAgentGateAddresses(contextGraphId); + if (!allowedAgents) { + return this.getDefaultWorkspaceGossipSigningAgent(); + } + + const allowedSet = new Set(allowedAgents.map((agent) => agent.toLowerCase())); + for (const record of this.localAgents.values()) { + if (record.privateKey && allowedSet.has(record.agentAddress.toLowerCase())) { + return { ...record, privateKey: record.privateKey }; + } + } + + throw new Error(`Cannot gossip SWM write for agent-gated context graph "${contextGraphId}": no local allowed signing agent key`); + } + + private async encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise { + const signer = await this.resolveWorkspaceGossipSigningAgent(contextGraphId); + if (!signer) { + return message; + } + + const timestamp = new Date().toISOString(); + const payload = new Uint8Array(message); + const signingPayload = computeGossipSigningPayload( + GOSSIP_TYPE_WORKSPACE_PUBLISH, + contextGraphId, + timestamp, + payload, + ); + const signature = await new ethers.Wallet(signer.privateKey).signMessage(signingPayload); + return encodeGossipEnvelope({ + version: GOSSIP_ENVELOPE_VERSION, + type: GOSSIP_TYPE_WORKSPACE_PUBLISH, + contextGraphId, + agentAddress: signer.agentAddress, + timestamp, + signature: ethers.getBytes(signature), + payload, + }); + } + + private async publishWorkspaceGossip( + contextGraphId: string, + message: Uint8Array, + ctx: OperationContext, + ): Promise { + const topic = paranetWorkspaceTopic(contextGraphId); + const wireMessage = await this.encodeWorkspaceGossipMessage(contextGraphId, message); + try { + await this.gossip.publish(topic, wireMessage); + } catch { + this.log.warn(ctx, `No peers subscribed to ${topic} yet`); + } + } + async publishAsync( contextGraphIdOrUal: string, content: PublishAsyncContent, @@ -2938,12 +3054,7 @@ export class DKGAgent { }); if (!opts?.localOnly) { - const topic = paranetWorkspaceTopic(contextGraphId); - try { - await this.gossip.publish(topic, message); - } catch { - this.log.warn(ctx, `No peers subscribed to ${topic} yet`); - } + await this.publishWorkspaceGossip(contextGraphId, message, ctx); } return { captureID }; @@ -3063,12 +3174,7 @@ export class DKGAgent { subGraphName: opts?.subGraphName, }); if (!opts?.localOnly) { - const topic = paranetWorkspaceTopic(contextGraphId); - try { - await this.gossip.publish(topic, message); - } catch { - this.log.warn(ctx, `No peers subscribed to ${topic} yet`); - } + await this.publishWorkspaceGossip(contextGraphId, message, ctx); } return { shareOperationId }; } @@ -3094,12 +3200,7 @@ export class DKGAgent { subGraphName: opts?.subGraphName, }); if (!opts?.localOnly) { - const topic = paranetWorkspaceTopic(contextGraphId); - try { - await this.gossip.publish(topic, message); - } catch { - this.log.warn(ctx, `No peers subscribed to ${topic} yet`); - } + await this.publishWorkspaceGossip(contextGraphId, message, ctx); } return { shareOperationId }; } @@ -3767,6 +3868,7 @@ export class DKGAgent { this.sharedMemoryHandler = new SharedMemoryHandler(this.store, this.eventBus, { sharedMemoryOwnedEntities: this.workspaceOwnedEntities, writeLocks: this.writeLocks, + localAgentAddresses: () => [...this.localAgents.keys()], }); } return this.sharedMemoryHandler; @@ -7929,9 +8031,8 @@ export class DKGAgent { { ...opts, publisherPeerId: agent.node.peerId.toString() }, ); if (gossipMessage) { - const topic = paranetWorkspaceTopic(contextGraphId); try { - await agent.gossip.publish(topic, gossipMessage); + await agent.publishWorkspaceGossip(contextGraphId, gossipMessage, createOperationContext('share')); } catch (err: any) { agent.log.warn(createOperationContext('share'), `Promote gossip failed (local SWM committed): ${err?.message ?? err}`); } diff --git a/packages/agent/test/agent-audit-extra.test.ts b/packages/agent/test/agent-audit-extra.test.ts index 3f04805c0..5f4faa68f 100644 --- a/packages/agent/test/agent-audit-extra.test.ts +++ b/packages/agent/test/agent-audit-extra.test.ts @@ -55,6 +55,9 @@ import { decodeWorkspacePublishRequest, encodeFinalizationMessage, decodeGossipEnvelope, + computeGossipSigningPayload, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, } from '@origintrail-official/dkg-core'; import { SharedMemoryHandler, @@ -467,7 +470,7 @@ describe('[A-12] DID format drift in agent.endorse', () => { }); describe('[A-15] Publisher signs every gossip message (SWM share)', () => { - it('PROD-BUG: DKGAgent.share emits raw WorkspacePublishRequest bytes — NOT wrapped in a signed GossipEnvelope', async () => { + it('DKGAgent.share emits a signed GossipEnvelope that carries the WorkspacePublishRequest', async () => { const agent = await makeAgent('A15-Share'); // Intercept libp2p pubsub publish to capture the raw wire bytes without @@ -490,30 +493,23 @@ describe('[A-15] Publisher signs every gossip message (SWM share)', () => { const shareMsg = captured.find(c => c.topic.includes('shared-memory')); expect(shareMsg, `expected a shared-memory gossip publish; saw: ${captured.map(c => c.topic).join(', ')}`).toBeTruthy(); - // ① The bytes successfully decode as WorkspacePublishRequest (raw payload). - const decoded = decodeWorkspacePublishRequest(shareMsg!.data); + const envelope = decodeGossipEnvelope(shareMsg!.data); + expect(envelope.version).toBe(GOSSIP_ENVELOPE_VERSION); + expect(envelope.type).toBe(GOSSIP_TYPE_WORKSPACE_PUBLISH); + expect(envelope.contextGraphId).toBe(CG); + expect(envelope.signature.length).toBeGreaterThan(0); + + const signingPayload = computeGossipSigningPayload( + envelope.type, + envelope.contextGraphId, + envelope.timestamp, + envelope.payload, + ); + const recovered = ethers.verifyMessage(signingPayload, ethers.hexlify(envelope.signature)); + expect(recovered.toLowerCase()).toBe(envelope.agentAddress.toLowerCase()); + + const decoded = decodeWorkspacePublishRequest(envelope.payload); expect(decoded.paranetId).toBe(CG); expect(decoded.publisherPeerId).toBe(agent.peerId); - - // ② When decoded as a GossipEnvelope (spec — §GossipEnvelopeSchema), - // the signature field is EMPTY. Protobuf decode will not throw - // because the wire types happen to align, but `signature.length` - // is zero, proving nothing was signed. - let envelopeView: any = undefined; - try { - envelopeView = decodeGossipEnvelope(shareMsg!.data); - } catch { - // Some permutations of wire layout will throw — that is ALSO a pass - // for this assertion: if it doesn't even parse as a GossipEnvelope, - // then it certainly isn't a signed GossipEnvelope. - } - if (envelopeView) { - const sig: Uint8Array | undefined = envelopeView.signature; - const sigLen = sig ? sig.length : 0; - // PROD-BUG (audit A-15): V10 requires every gossip message to ride - // inside a signed envelope. The WM share path bypasses the envelope - // entirely, so there is no signature to verify. - expect(sigLen).toBe(0); - } }, 20_000); }); diff --git a/packages/core/src/proto/gossip-envelope.ts b/packages/core/src/proto/gossip-envelope.ts index 341fd4558..8484f6b3e 100644 --- a/packages/core/src/proto/gossip-envelope.ts +++ b/packages/core/src/proto/gossip-envelope.ts @@ -43,6 +43,10 @@ export interface GossipEnvelopeMsg { payload: Uint8Array; } +export const GOSSIP_ENVELOPE_VERSION = '10.0.0'; +export const GOSSIP_TYPE_WORKSPACE_PUBLISH = 'share-write'; +export const GOSSIP_ENVELOPE_FRESHNESS_MS = 5 * 60 * 1000; + export function encodeGossipEnvelope(msg: GossipEnvelopeMsg): Uint8Array { return GossipEnvelopeSchema.encode( GossipEnvelopeSchema.create(msg), @@ -55,9 +59,23 @@ export function decodeGossipEnvelope(buf: Uint8Array): GossipEnvelopeMsg { const textEncoder = new TextEncoder(); +function uint32Be(value: number): Uint8Array { + const buf = new Uint8Array(4); + new DataView(buf.buffer).setUint32(0, value, false); + return buf; +} + +function framedField(value: Uint8Array): Uint8Array { + const len = uint32Be(value.length); + const framed = new Uint8Array(len.length + value.length); + framed.set(len, 0); + framed.set(value, len.length); + return framed; +} + /** * Compute the signing payload for a gossip envelope. - * Signs: type + contextGraphId + timestamp + payload + * Signs length-framed fields: type, contextGraphId, timestamp, payload. */ export function computeGossipSigningPayload( type: string, @@ -65,9 +83,18 @@ export function computeGossipSigningPayload( timestamp: string, payload: Uint8Array, ): Uint8Array { - const prefix = textEncoder.encode(`${type}${contextGraphId}${timestamp}`); - const combined = new Uint8Array(prefix.length + payload.length); - combined.set(prefix, 0); - combined.set(payload, prefix.length); + const fields = [ + framedField(textEncoder.encode(type)), + framedField(textEncoder.encode(contextGraphId)), + framedField(textEncoder.encode(timestamp)), + framedField(payload), + ]; + const total = fields.reduce((sum, field) => sum + field.length, 0); + const combined = new Uint8Array(total); + let offset = 0; + for (const field of fields) { + combined.set(field, offset); + offset += field.length; + } return combined; } diff --git a/packages/core/src/proto/index.ts b/packages/core/src/proto/index.ts index a9ed1a0d5..e082ae27e 100644 --- a/packages/core/src/proto/index.ts +++ b/packages/core/src/proto/index.ts @@ -86,6 +86,9 @@ export { export { type GossipEnvelopeMsg, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, + GOSSIP_ENVELOPE_FRESHNESS_MS, encodeGossipEnvelope, decodeGossipEnvelope, computeGossipSigningPayload, diff --git a/packages/core/test/v10-proto.test.ts b/packages/core/test/v10-proto.test.ts index 83a3cf7d2..43cf6fc6a 100644 --- a/packages/core/test/v10-proto.test.ts +++ b/packages/core/test/v10-proto.test.ts @@ -196,11 +196,15 @@ describe('computeGossipSigningPayload', () => { expect(a).not.toEqual(b); }); - it('concatenates prefix with payload bytes', () => { + it('length-frames fields before payload bytes', () => { const payload = new Uint8Array([0xde, 0xad]); const result = computeGossipSigningPayload('t', 'c', '1', payload); - const prefix = new TextEncoder().encode('tc1'); - expect(result).toEqual(new Uint8Array([...prefix, 0xde, 0xad])); + expect(result).toEqual(new Uint8Array([ + 0, 0, 0, 1, 0x74, + 0, 0, 0, 1, 0x63, + 0, 0, 0, 1, 0x31, + 0, 0, 0, 2, 0xde, 0xad, + ])); }); }); diff --git a/packages/core/test/v10-protocol-coverage.test.ts b/packages/core/test/v10-protocol-coverage.test.ts index a8279e952..73085b3db 100644 --- a/packages/core/test/v10-protocol-coverage.test.ts +++ b/packages/core/test/v10-protocol-coverage.test.ts @@ -15,9 +15,8 @@ import { // ───────────────────────────────────────────────────────────────────────────── // Audit findings covered: // -// C-6 computeGossipSigningPayload concatenates type|cgId|timestamp|payload -// with no length prefix. ('a'+'bc') and ('ab'+'c') would produce the -// same prefix ('abc'). Test that length-confusion is detected. +// C-6 computeGossipSigningPayload must length-frame type|cgId|timestamp|payload +// so ('a'+'bc') and ('ab'+'c') cannot produce the same signed bytes. // // C-7 Existing v10-proto.test.ts round-trips do NOT assert nodeIdentityId // on StorageACK or verifiedMemoryId/batchId on VerifyProposal. A proto @@ -44,21 +43,12 @@ function bytes(n: number, fill = 0): Uint8Array { } describe('computeGossipSigningPayload — length-confusion safety [C-6]', () => { - // The signing payload is `${type}${contextGraphId}${timestamp}` || payload. - // Without length prefixes between fields, an attacker who controls one - // field can shift bytes between fields and produce the same signed bytes. - // This test pins the current behaviour as a finding: a single-byte shift - // across the type / contextGraphId boundary collides today. - - it('same concatenation byte-stream from different field splits collides (length-confusion)', () => { + it('same concatenation byte-stream from different field splits does not collide', () => { const ts = '2026-01-01T00:00:00Z'; const payload = new Uint8Array([1, 2, 3, 4]); const a = computeGossipSigningPayload('a', 'bc' + 'tail', ts, payload); const b = computeGossipSigningPayload('ab', 'c' + 'tail', ts, payload); - // If this passes (a equals b), the protocol is vulnerable to type/cgId - // length confusion. If it fails (a !== b), the implementation has been - // hardened with length prefixes — update the test to reflect the fix. - expect(a).toEqual(b); + expect(a).not.toEqual(b); }); it('different timestamps produce different payloads', () => { @@ -73,14 +63,18 @@ describe('computeGossipSigningPayload — length-confusion safety [C-6]', () => expect(a).not.toEqual(b); }); - it('payload bytes appear AFTER the prefix (regression: prefix before payload)', () => { + it('payload bytes are length-framed after metadata fields', () => { const type = 'T'; const cgId = 'CG'; const ts = 'TS'; const payload = new Uint8Array([0xAB, 0xCD]); const p = computeGossipSigningPayload(type, cgId, ts, payload); - // Expected: 'T' 'C' 'G' 'T' 'S' 0xAB 0xCD = bytes 84,67,71,84,83,171,205 - expect(Array.from(p)).toEqual([84, 67, 71, 84, 83, 0xAB, 0xCD]); + expect(Array.from(p)).toEqual([ + 0, 0, 0, 1, 84, + 0, 0, 0, 2, 67, 71, + 0, 0, 0, 2, 84, 83, + 0, 0, 0, 2, 0xAB, 0xCD, + ]); }); }); diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index aacad89d0..95ed0b861 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -3,14 +3,32 @@ import { GraphManager } from '@origintrail-official/dkg-storage'; import type { EventBus } from '@origintrail-official/dkg-core'; import { Logger, createOperationContext, contextGraphDataUri, contextGraphMetaUri } from '@origintrail-official/dkg-core'; import type { PhaseCallback } from './publisher.js'; -import { decodeWorkspacePublishRequest, assertSafeIri, assertSafeRdfTerm, validateSubGraphName, contextGraphSubGraphUri } from '@origintrail-official/dkg-core'; -import type { WorkspaceCASConditionMsg } from '@origintrail-official/dkg-core'; +import { + decodeGossipEnvelope, + decodeWorkspacePublishRequest, + computeGossipSigningPayload, + assertSafeIri, + assertSafeRdfTerm, + validateSubGraphName, + contextGraphSubGraphUri, + GOSSIP_ENVELOPE_FRESHNESS_MS, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, +} from '@origintrail-official/dkg-core'; +import type { GossipEnvelopeMsg, WorkspaceCASConditionMsg, WorkspacePublishRequestMsg } from '@origintrail-official/dkg-core'; +import { ethers } from 'ethers'; import { validatePublishRequest } from './validation.js'; import { generateShareMetadata, generateOwnershipQuads, generateSubGraphRegistration } from './metadata.js'; import { parseSimpleNQuads } from './publish-handler.js'; import { storeWorkspaceOperationPublicQuads } from './workspace-resolution.js'; import type { KAManifestEntry } from './publisher.js'; +interface WorkspaceGossipDecodeResult { + request: WorkspacePublishRequestMsg; + envelope?: GossipEnvelopeMsg; + payload: Uint8Array; +} + /** * Handles incoming shared memory topic messages (GossipSub). * Validates the request, stores public triples into SWM graph @@ -23,6 +41,8 @@ export class SharedMemoryHandler { /** Per-context-graph map of rootEntity → creatorPeerId. Shared with publisher when used by agent. */ private readonly sharedMemoryOwnedEntities: Map> = new Map(); private readonly writeLocks: Map>; + private readonly localAgentAddresses?: () => readonly string[] | Promise; + private readonly now: () => number; private readonly log = new Logger('SharedMemoryHandler'); constructor( @@ -31,6 +51,8 @@ export class SharedMemoryHandler { options?: { sharedMemoryOwnedEntities?: Map>; writeLocks?: Map>; + localAgentAddresses?: () => readonly string[] | Promise; + now?: () => number; }, ) { this.store = store; @@ -40,6 +62,8 @@ export class SharedMemoryHandler { this.sharedMemoryOwnedEntities = options.sharedMemoryOwnedEntities; } this.writeLocks = options?.writeLocks ?? new Map(); + this.localAgentAddresses = options?.localAgentAddresses; + this.now = options?.now ?? (() => Date.now()); } private async withWriteLocks(keys: string[], fn: () => Promise): Promise { @@ -121,7 +145,8 @@ export class SharedMemoryHandler { let ctx = createOperationContext('share'); try { onPhase?.('decode', 'start'); - const request = decodeWorkspacePublishRequest(data); + const decoded = this.decodeWorkspaceGossipMessage(data); + const { request, envelope, payload } = decoded; if (request.operationId) { ctx = createOperationContext('share', request.operationId); } @@ -135,6 +160,12 @@ export class SharedMemoryHandler { return; } + const allowedAgents = await this.getContextGraphAllowedAgents(contextGraphId); + if (allowedAgents !== null) { + const verified = await this.verifyAgentEnvelope(envelope, payload, contextGraphId, allowedAgents, ctx); + if (!verified) return; + } + // Enforce peer allowlist for curated CGs const allowedPeers = await this.getContextGraphAllowedPeers(contextGraphId); if (allowedPeers !== null && !allowedPeers.includes(fromPeerId)) { @@ -314,6 +345,100 @@ export class SharedMemoryHandler { } } + private decodeWorkspaceGossipMessage(data: Uint8Array): WorkspaceGossipDecodeResult { + try { + const envelope = decodeGossipEnvelope(data); + if ( + envelope.version === GOSSIP_ENVELOPE_VERSION && + envelope.type === GOSSIP_TYPE_WORKSPACE_PUBLISH && + envelope.payload && + envelope.payload.length > 0 + ) { + return { + request: decodeWorkspacePublishRequest(envelope.payload), + envelope, + payload: new Uint8Array(envelope.payload), + }; + } + } catch { + // Legacy raw workspace messages are still valid for non-agent-gated CGs. + } + return { + request: decodeWorkspacePublishRequest(data), + payload: data, + }; + } + + private async verifyAgentEnvelope( + envelope: GossipEnvelopeMsg | undefined, + payload: Uint8Array, + contextGraphId: string, + allowedAgents: string[], + ctx: import('@origintrail-official/dkg-core').OperationContext, + ): Promise { + if (!envelope) { + this.log.warn(ctx, `SWM write rejected: unsigned workspace gossip for agent-gated context graph "${contextGraphId}"`); + return false; + } + + if (envelope.version !== GOSSIP_ENVELOPE_VERSION || envelope.type !== GOSSIP_TYPE_WORKSPACE_PUBLISH) { + this.log.warn(ctx, `SWM write rejected: invalid gossip envelope type/version for context graph "${contextGraphId}"`); + return false; + } + if (envelope.contextGraphId !== contextGraphId) { + this.log.warn(ctx, `SWM write rejected: envelope contextGraphId "${envelope.contextGraphId}" does not match payload "${contextGraphId}"`); + return false; + } + if (!envelope.signature || envelope.signature.length === 0) { + this.log.warn(ctx, `SWM write rejected: missing agent signature for context graph "${contextGraphId}"`); + return false; + } + + const timestampMs = Date.parse(envelope.timestamp); + if (!Number.isFinite(timestampMs) || Math.abs(this.now() - timestampMs) > GOSSIP_ENVELOPE_FRESHNESS_MS) { + this.log.warn(ctx, `SWM write rejected: stale or invalid gossip timestamp "${envelope.timestamp}"`); + return false; + } + + let claimedAgent: string; + let recovered: string; + try { + claimedAgent = ethers.getAddress(envelope.agentAddress); + const signingPayload = computeGossipSigningPayload( + envelope.type, + envelope.contextGraphId, + envelope.timestamp, + payload, + ); + recovered = ethers.verifyMessage(signingPayload, ethers.hexlify(envelope.signature)); + } catch (err) { + this.log.warn(ctx, `SWM write rejected: invalid agent signature (${err instanceof Error ? err.message : String(err)})`); + return false; + } + + if (recovered.toLowerCase() !== claimedAgent.toLowerCase()) { + this.log.warn(ctx, `SWM write rejected: recovered signer ${recovered} does not match envelope agent ${claimedAgent}`); + return false; + } + + const allowedSet = new Set(allowedAgents.map((agent) => agent.toLowerCase())); + if (!allowedSet.has(recovered.toLowerCase())) { + this.log.warn(ctx, `SWM write rejected: agent ${recovered} is not allowed for context graph "${contextGraphId}"`); + return false; + } + + if (this.localAgentAddresses) { + const localAgents = await this.localAgentAddresses(); + const localAllowed = localAgents.some((agent) => allowedSet.has(agent.toLowerCase())); + if (!localAllowed) { + this.log.warn(ctx, `SWM write rejected: local node has no allowed agent for context graph "${contextGraphId}"`); + return false; + } + } + + return true; + } + /** * Returns the peer allowlist for a context graph, or null if no allowlist * is set (open CG — all peers allowed). @@ -331,7 +456,36 @@ export class SharedMemoryHandler { return result.bindings .map(row => row['peer']) .filter((v): v is string => typeof v === 'string') - .map(v => v.replace(/^"|"$/g, '')); + .map(stripRdfLiteral); + } + + /** + * Returns the agent-address allowlist for a context graph, or null if the + * graph is not agent-gated. Includes local allowedAgent entries and + * on-chain participantAgent metadata. + */ + private async getContextGraphAllowedAgents(contextGraphId: string): Promise { + const DKG_ALLOWED_AGENT = 'https://dkg.network/ontology#allowedAgent'; + const DKG_PARTICIPANT_AGENT = 'https://dkg.network/ontology#participantAgent'; + const cgMeta = contextGraphMetaUri(contextGraphId); + const cgData = contextGraphDataUri(contextGraphId); + const result = await this.store.query( + `SELECT ?agent WHERE { GRAPH <${cgMeta}> { + { <${cgData}> <${DKG_ALLOWED_AGENT}> ?agent } + UNION + { <${cgData}> <${DKG_PARTICIPANT_AGENT}> ?agent } + } }`, + ); + if (result.type !== 'bindings' || result.bindings.length === 0) { + return null; + } + const agents = result.bindings + .map(row => row['agent']) + .filter((v): v is string => typeof v === 'string') + .map(stripRdfLiteral) + .filter((v) => ethers.isAddress(v)) + .map((v) => ethers.getAddress(v)); + return [...new Set(agents)]; } /** @@ -374,3 +528,9 @@ function parseCountLiteral(val: string | false | undefined): number { const n = Number(stripped); return Number.isFinite(n) ? n : NaN; } + +function stripRdfLiteral(value: string): string { + return value + .replace(/^"/, '') + .replace(/"(@[a-zA-Z-]+|\^\^<[^>]+>)?$/, ''); +} diff --git a/packages/publisher/test/workspace.test.ts b/packages/publisher/test/workspace.test.ts index 72ba43d3a..e525a75af 100644 --- a/packages/publisher/test/workspace.test.ts +++ b/packages/publisher/test/workspace.test.ts @@ -2,8 +2,15 @@ import { describe, it, expect, beforeEach, beforeAll, afterAll, afterEach } from import { OxigraphStore, type Quad } from '@origintrail-official/dkg-storage'; import { GraphManager } from '@origintrail-official/dkg-storage'; import { EVMChainAdapter } from '@origintrail-official/dkg-chain'; -import { TypedEventBus } from '@origintrail-official/dkg-core'; -import { generateEd25519Keypair } from '@origintrail-official/dkg-core'; +import { + TypedEventBus, + generateEd25519Keypair, + encodeWorkspacePublishRequest, + encodeGossipEnvelope, + computeGossipSigningPayload, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, +} from '@origintrail-official/dkg-core'; import { DKGPublisher, SharedMemoryHandler, @@ -25,6 +32,30 @@ function q(s: string, p: string, o: string, g = ''): Quad { return { subject: s, predicate: p, object: o, graph: g }; } +async function signWorkspaceMessage( + wallet: ethers.Wallet, + contextGraphId: string, + payload: Uint8Array, + timestamp = new Date().toISOString(), +): Promise { + const signingPayload = computeGossipSigningPayload( + GOSSIP_TYPE_WORKSPACE_PUBLISH, + contextGraphId, + timestamp, + payload, + ); + const signature = await wallet.signMessage(signingPayload); + return encodeGossipEnvelope({ + version: GOSSIP_ENVELOPE_VERSION, + type: GOSSIP_TYPE_WORKSPACE_PUBLISH, + contextGraphId, + agentAddress: wallet.address, + timestamp, + signature: ethers.getBytes(signature), + payload, + }); +} + beforeAll(async () => { const cgId = await createTestContextGraph(); PARANET = String(cgId); @@ -504,6 +535,144 @@ describe('SharedMemoryHandler', () => { expect(workspaceOwned.get(PARANET)?.has(ENTITY)).toBe(true); }); + it('rejects raw workspace gossip when the context graph is agent-gated', async () => { + const wallet = ethers.Wallet.createRandom(); + await store.insert([{ + subject: DATA_GRAPH, + predicate: 'https://dkg.network/ontology#allowedAgent', + object: `"${wallet.address}"`, + graph: `did:dkg:context-graph:${PARANET}/_meta`, + }]); + + const nquads = `<${ENTITY}> "Unsigned" <${DATA_GRAPH}> .`; + const msg = encodeWorkspacePublishRequest({ + paranetId: PARANET, + nquads: new TextEncoder().encode(nquads), + manifest: [{ rootEntity: ENTITY, privateTripleCount: 0 }], + publisherPeerId: '12D3KooWPeer', + workspaceOperationId: 'ws-unsigned-agent-gate', + timestampMs: Date.now(), + }); + + await handler.handle(msg, '12D3KooWPeer'); + + const gm = new GraphManager(store); + await gm.ensureContextGraph(PARANET); + const askResult = await store.query( + `ASK { GRAPH <${gm.workspaceGraphUri(PARANET)}> { <${ENTITY}> ?p ?o } }`, + ); + expect(askResult.type).toBe('boolean'); + if (askResult.type === 'boolean') { + expect(askResult.value).toBe(false); + } + }); + + it('treats malformed allowedAgent metadata as gated instead of open', async () => { + await store.insert([{ + subject: DATA_GRAPH, + predicate: 'https://dkg.network/ontology#allowedAgent', + object: '"not-an-address"', + graph: `did:dkg:context-graph:${PARANET}/_meta`, + }]); + + const nquads = `<${ENTITY}> "Malformed Gate" <${DATA_GRAPH}> .`; + const msg = encodeWorkspacePublishRequest({ + paranetId: PARANET, + nquads: new TextEncoder().encode(nquads), + manifest: [{ rootEntity: ENTITY, privateTripleCount: 0 }], + publisherPeerId: '12D3KooWPeer', + workspaceOperationId: 'ws-malformed-agent-gate', + timestampMs: Date.now(), + }); + + await handler.handle(msg, '12D3KooWPeer'); + + const gm = new GraphManager(store); + await gm.ensureContextGraph(PARANET); + const askResult = await store.query( + `ASK { GRAPH <${gm.workspaceGraphUri(PARANET)}> { <${ENTITY}> ?p ?o } }`, + ); + expect(askResult.type).toBe('boolean'); + if (askResult.type === 'boolean') { + expect(askResult.value).toBe(false); + } + }); + + it('accepts signed workspace gossip from an allowed agent', async () => { + const wallet = ethers.Wallet.createRandom(); + handler = new SharedMemoryHandler(store, new TypedEventBus(), { + sharedMemoryOwnedEntities: workspaceOwned, + localAgentAddresses: () => [wallet.address], + }); + await store.insert([{ + subject: DATA_GRAPH, + predicate: 'https://dkg.network/ontology#allowedAgent', + object: `"${wallet.address}"`, + graph: `did:dkg:context-graph:${PARANET}/_meta`, + }]); + + const nquads = `<${ENTITY}> "Signed" <${DATA_GRAPH}> .`; + const raw = encodeWorkspacePublishRequest({ + paranetId: PARANET, + nquads: new TextEncoder().encode(nquads), + manifest: [{ rootEntity: ENTITY, privateTripleCount: 0 }], + publisherPeerId: '12D3KooWPeer', + workspaceOperationId: 'ws-signed-agent-gate', + timestampMs: Date.now(), + }); + const msg = await signWorkspaceMessage(wallet, PARANET, raw); + + await handler.handle(msg, '12D3KooWPeer'); + + const gm = new GraphManager(store); + await gm.ensureContextGraph(PARANET); + const result = await store.query( + `SELECT ?o WHERE { GRAPH <${gm.workspaceGraphUri(PARANET)}> { <${ENTITY}> ?o } }`, + ); + expect(result.type).toBe('bindings'); + if (result.type === 'bindings') { + expect(result.bindings[0]?.['o']).toBe('"Signed"'); + } + }); + + it('rejects signed workspace gossip from an agent outside the allowlist', async () => { + const allowed = ethers.Wallet.createRandom(); + const denied = ethers.Wallet.createRandom(); + handler = new SharedMemoryHandler(store, new TypedEventBus(), { + sharedMemoryOwnedEntities: workspaceOwned, + localAgentAddresses: () => [allowed.address], + }); + await store.insert([{ + subject: DATA_GRAPH, + predicate: 'https://dkg.network/ontology#allowedAgent', + object: `"${allowed.address}"`, + graph: `did:dkg:context-graph:${PARANET}/_meta`, + }]); + + const nquads = `<${ENTITY}> "Denied" <${DATA_GRAPH}> .`; + const raw = encodeWorkspacePublishRequest({ + paranetId: PARANET, + nquads: new TextEncoder().encode(nquads), + manifest: [{ rootEntity: ENTITY, privateTripleCount: 0 }], + publisherPeerId: '12D3KooWPeer', + workspaceOperationId: 'ws-denied-agent-gate', + timestampMs: Date.now(), + }); + const msg = await signWorkspaceMessage(denied, PARANET, raw); + + await handler.handle(msg, '12D3KooWPeer'); + + const gm = new GraphManager(store); + await gm.ensureContextGraph(PARANET); + const askResult = await store.query( + `ASK { GRAPH <${gm.workspaceGraphUri(PARANET)}> { <${ENTITY}> ?p ?o } }`, + ); + expect(askResult.type).toBe('boolean'); + if (askResult.type === 'boolean') { + expect(askResult.value).toBe(false); + } + }); + it('rejects message when rootEntity was created by a different peer (Rule 4)', async () => { const { encodeWorkspacePublishRequest } = await import('@origintrail-official/dkg-core'); workspaceOwned.set(PARANET, new Map([[ENTITY, 'otherPeer']])); From b7967cdf7379215235145c1e01ce7dce87dff66e Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Tue, 5 May 2026 12:00:14 +0200 Subject: [PATCH 2/4] implement focused change --- ARCHITECTURE.md | 87 ++++++++++++++++++- packages/agent/src/dkg-agent.ts | 17 ++-- .../agent/test/swm-gossip-signing.test.ts | 51 +++++++++++ 3 files changed, 147 insertions(+), 8 deletions(-) create mode 100644 packages/agent/test/swm-gossip-signing.test.ts diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index ee418dc5c..a964de85c 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -52,6 +52,29 @@ class DaemonHTTPAPI { class DKGAgent { +writeWorkingMemory() +promoteSharedMemory() + +encodeWorkspaceGossipMessage() + +publishWorkspaceGossip() +} +class AgentKeyStore { + +selectDefaultOrFallbackSigner() + +selectAllowedSigner(contextGraphId) +} +class AgentGateMetadata { + +DKG_ALLOWED_AGENT + +DKG_PARTICIPANT_AGENT +} +class GossipEnvelope { + +version + +type + +contextGraphId + +agentAddress + +timestamp + +signature + +payload +} +class SharedMemoryHandler { + +handle(data, from) + +verifyAgentEnvelope() } class AsyncPublisher { +enqueue() @@ -75,10 +98,72 @@ DaemonHTTPAPI --> DKGAgent : delegates memory writes DaemonHTTPAPI --> AsyncPublisher : delegates lift jobs DKGAgent --> WorkingMemory : owns DKGAgent --> SharedWorkingMemory : gossips +DKGAgent --> AgentKeyStore : selects local signing agent +DKGAgent --> AgentGateMetadata : reads agent gates +DKGAgent --> GossipEnvelope : wraps signed SWM gossip +GossipEnvelope --> SharedMemoryHandler : delivered on SWM topic +SharedMemoryHandler --> AgentGateMetadata : authorizes gated writers +SharedMemoryHandler --> SharedWorkingMemory : stores accepted writes AsyncPublisher --> SharedWorkingMemory : reads source data AsyncPublisher --> VerifiedMemory : publishes ``` +## Shared Memory Gossip Authentication + +Shared Working Memory gossip is authenticated at the agent layer when a local +agent private key is available. For non-agent-gated context graphs, the sender +prefers the configured default agent key and falls back to another local signing +agent; if no local signing key exists, the legacy raw SWM payload remains valid. + +For agent-gated context graphs, `DKG_ALLOWED_AGENT` and +`DKG_PARTICIPANT_AGENT` metadata define the accepted writer set. Outgoing SWM +gossip must be signed by one of those local agents, otherwise the write is not +broadcast. Receivers accept legacy raw SWM only when the graph is not +agent-gated. For gated graphs, `SharedMemoryHandler` requires a current signed +`GossipEnvelope`, verifies the claimed agent address against the recovered +signature, checks that the envelope context graph matches the payload, and +rejects writers outside the allowed or participant agent set. + +```mermaid +sequenceDiagram +actor Writer as Local agent process +participant Agent as DKGAgent +participant Keys as LocalAgentKeys +participant Meta as ContextGraphMeta +participant Gossip as GossipSub +participant Handler as SharedMemoryHandler +participant SWM as SharedWorkingMemory + +Writer->>Agent: share or promote SWM write +Agent->>Meta: read DKG_ALLOWED_AGENT and DKG_PARTICIPANT_AGENT +alt context graph is agent-gated + Agent->>Keys: select local allowed signing key + alt no allowed private key + Agent-->>Writer: abort SWM gossip + else allowed private key exists + Agent->>Agent: encode signed GossipEnvelope + Agent->>Gossip: publish signed envelope + end +else context graph is not agent-gated + Agent->>Keys: select default or fallback local signing key + alt signing key exists + Agent->>Agent: encode signed GossipEnvelope + Agent->>Gossip: publish signed envelope + else no signing key + Agent->>Gossip: publish legacy raw SWM payload + end +end +Gossip->>Handler: deliver SWM topic message +Handler->>Meta: read accepted agent writers +alt receiver graph is agent-gated + Handler->>Handler: require envelope and verify signature, timestamp, and writer + Handler->>SWM: store accepted write +else receiver graph is not agent-gated + Handler->>Handler: decode envelope or legacy raw payload + Handler->>SWM: store accepted write +end +``` + ## Source Worker Workflow Source-worker configuration is sensitive operator material. It contains the @@ -157,7 +242,7 @@ participant Architecture as ARCHITECTURE.md participant Git as LocalGit User->>Workflow: continue from failure checkpoint -Workflow->>Implementer: implement focused source-worker fix +Workflow->>Implementer: implement focused code change Implementer-->>Workflow: code and tests changed Workflow->>Validation: run focused validation and code review Validation-->>Workflow: passed diff --git a/packages/agent/src/dkg-agent.ts b/packages/agent/src/dkg-agent.ts index 7842010ec..207115a03 100644 --- a/packages/agent/src/dkg-agent.ts +++ b/packages/agent/src/dkg-agent.ts @@ -2865,15 +2865,18 @@ export class DKGAgent { return this._publish(contextGraphId, input as Quad[], undefined, thirdArg ?? fourthArg); } - private getDefaultWorkspaceGossipSigningAgent(): (AgentKeyRecord & { privateKey: string }) | null { - if (!this.defaultAgentAddress) return null; - const defaultAddress = this.defaultAgentAddress.toLowerCase(); + private getWorkspaceGossipSigningAgent(): (AgentKeyRecord & { privateKey: string }) | null { + const defaultAddress = this.defaultAgentAddress?.toLowerCase(); + let fallback: (AgentKeyRecord & { privateKey: string }) | null = null; for (const record of this.localAgents.values()) { - if (record.agentAddress.toLowerCase() === defaultAddress && record.privateKey) { - return { ...record, privateKey: record.privateKey }; + if (!record.privateKey) continue; + const signingRecord = { ...record, privateKey: record.privateKey }; + if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) { + return signingRecord; } + fallback ??= signingRecord; } - return null; + return fallback; } private async getContextGraphAgentGateAddresses(contextGraphId: string): Promise { @@ -2924,7 +2927,7 @@ export class DKGAgent { ): Promise<(AgentKeyRecord & { privateKey: string }) | null> { const allowedAgents = await this.getContextGraphAgentGateAddresses(contextGraphId); if (!allowedAgents) { - return this.getDefaultWorkspaceGossipSigningAgent(); + return this.getWorkspaceGossipSigningAgent(); } const allowedSet = new Set(allowedAgents.map((agent) => agent.toLowerCase())); diff --git a/packages/agent/test/swm-gossip-signing.test.ts b/packages/agent/test/swm-gossip-signing.test.ts new file mode 100644 index 000000000..cbdeae013 --- /dev/null +++ b/packages/agent/test/swm-gossip-signing.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { + computeGossipSigningPayload, + decodeGossipEnvelope, + GOSSIP_TYPE_WORKSPACE_PUBLISH, + GOSSIP_ENVELOPE_VERSION, +} from '@origintrail-official/dkg-core'; +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../src/index.js'; + +interface DKGAgentInternals { + localAgents: Map; + defaultAgentAddress?: string; + encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise; +} + +describe('DKGAgent SWM gossip signing', () => { + it('wraps open-graph SWM gossip with a local agent key even when the default cannot sign', async () => { + const agent = await DKGAgent.create({ + name: 'SwmFallbackSigner', + chainAdapter: new MockChainAdapter(), + }); + const internals = agent as unknown as DKGAgentInternals; + + const defaultRecord = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'default'); + delete defaultRecord.privateKey; + const fallbackRecord = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'fallback'); + + internals.localAgents.set(defaultRecord.agentAddress, defaultRecord); + internals.localAgents.set(fallbackRecord.agentAddress, fallbackRecord); + internals.defaultAgentAddress = defaultRecord.agentAddress; + + const contextGraphId = 'open-swm-cg'; + const payload = new TextEncoder().encode('raw shared-memory payload'); + const wireMessage = await internals.encodeWorkspaceGossipMessage(contextGraphId, payload); + const envelope = decodeGossipEnvelope(wireMessage); + + expect(envelope.version).toBe(GOSSIP_ENVELOPE_VERSION); + expect(envelope.type).toBe(GOSSIP_TYPE_WORKSPACE_PUBLISH); + expect(envelope.contextGraphId).toBe(contextGraphId); + expect(envelope.agentAddress).toBe(fallbackRecord.agentAddress); + expect(Array.from(envelope.payload)).toEqual(Array.from(payload)); + + const recovered = ethers.verifyMessage( + computeGossipSigningPayload(envelope.type, envelope.contextGraphId, envelope.timestamp, envelope.payload), + ethers.hexlify(envelope.signature), + ); + expect(recovered).toBe(fallbackRecord.agentAddress); + }); +}); From abf310e18d2de2d9e81661047679c7e56a64a353 Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Tue, 5 May 2026 12:16:02 +0200 Subject: [PATCH 3/4] cover behavior --- .../agent/test/swm-gossip-signing.test.ts | 112 +++++++++++-- .../test/workspace-handler-agent-gate.test.ts | 154 ++++++++++++++++++ 2 files changed, 253 insertions(+), 13 deletions(-) create mode 100644 packages/publisher/test/workspace-handler-agent-gate.test.ts diff --git a/packages/agent/test/swm-gossip-signing.test.ts b/packages/agent/test/swm-gossip-signing.test.ts index cbdeae013..4e7296159 100644 --- a/packages/agent/test/swm-gossip-signing.test.ts +++ b/packages/agent/test/swm-gossip-signing.test.ts @@ -3,8 +3,11 @@ import { ethers } from 'ethers'; import { computeGossipSigningPayload, decodeGossipEnvelope, + DKG_ONTOLOGY, GOSSIP_TYPE_WORKSPACE_PUBLISH, GOSSIP_ENVELOPE_VERSION, + contextGraphDataUri, + contextGraphMetaUri, } from '@origintrail-official/dkg-core'; import { MockChainAdapter } from '@origintrail-official/dkg-chain'; import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../src/index.js'; @@ -15,6 +18,41 @@ interface DKGAgentInternals { encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise; } +async function insertAgentGate( + agent: DKGAgent, + contextGraphId: string, + predicate: string, + agentAddress: string, +): Promise { + await agent.store.insert([{ + subject: contextGraphDataUri(contextGraphId), + predicate, + object: `"${agentAddress}"`, + graph: contextGraphMetaUri(contextGraphId), + }]); +} + +function expectSignedEnvelope( + wireMessage: Uint8Array, + contextGraphId: string, + payload: Uint8Array, + expectedAgentAddress: string, +): void { + const envelope = decodeGossipEnvelope(wireMessage); + + expect(envelope.version).toBe(GOSSIP_ENVELOPE_VERSION); + expect(envelope.type).toBe(GOSSIP_TYPE_WORKSPACE_PUBLISH); + expect(envelope.contextGraphId).toBe(contextGraphId); + expect(envelope.agentAddress).toBe(expectedAgentAddress); + expect(Array.from(envelope.payload)).toEqual(Array.from(payload)); + + const recovered = ethers.verifyMessage( + computeGossipSigningPayload(envelope.type, envelope.contextGraphId, envelope.timestamp, envelope.payload), + ethers.hexlify(envelope.signature), + ); + expect(recovered).toBe(expectedAgentAddress); +} + describe('DKGAgent SWM gossip signing', () => { it('wraps open-graph SWM gossip with a local agent key even when the default cannot sign', async () => { const agent = await DKGAgent.create({ @@ -34,18 +72,66 @@ describe('DKGAgent SWM gossip signing', () => { const contextGraphId = 'open-swm-cg'; const payload = new TextEncoder().encode('raw shared-memory payload'); const wireMessage = await internals.encodeWorkspaceGossipMessage(contextGraphId, payload); - const envelope = decodeGossipEnvelope(wireMessage); - - expect(envelope.version).toBe(GOSSIP_ENVELOPE_VERSION); - expect(envelope.type).toBe(GOSSIP_TYPE_WORKSPACE_PUBLISH); - expect(envelope.contextGraphId).toBe(contextGraphId); - expect(envelope.agentAddress).toBe(fallbackRecord.agentAddress); - expect(Array.from(envelope.payload)).toEqual(Array.from(payload)); - - const recovered = ethers.verifyMessage( - computeGossipSigningPayload(envelope.type, envelope.contextGraphId, envelope.timestamp, envelope.payload), - ethers.hexlify(envelope.signature), - ); - expect(recovered).toBe(fallbackRecord.agentAddress); + expectSignedEnvelope(wireMessage, contextGraphId, payload, fallbackRecord.agentAddress); + }); + + it.each([ + ['DKG_ALLOWED_AGENT', DKG_ONTOLOGY.DKG_ALLOWED_AGENT], + ['DKG_PARTICIPANT_AGENT', DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT], + ])('wraps agent-gated SWM gossip with the local %s key', async (_label, predicate) => { + const agent = await DKGAgent.create({ + name: 'SwmGatedSigner', + chainAdapter: new MockChainAdapter(), + }); + const internals = agent as unknown as DKGAgentInternals; + + const defaultRecord = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'default'); + const gatedRecord = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'gated'); + + internals.localAgents.set(defaultRecord.agentAddress, defaultRecord); + internals.localAgents.set(gatedRecord.agentAddress, gatedRecord); + internals.defaultAgentAddress = defaultRecord.agentAddress; + + const contextGraphId = `gated-swm-cg-${predicate.endsWith('allowedAgent') ? 'allowed' : 'participant'}`; + await insertAgentGate(agent, contextGraphId, predicate, gatedRecord.agentAddress); + + const payload = new TextEncoder().encode('gated shared-memory payload'); + const wireMessage = await internals.encodeWorkspaceGossipMessage(contextGraphId, payload); + + expectSignedEnvelope(wireMessage, contextGraphId, payload, gatedRecord.agentAddress); + }); + + it('rejects outgoing agent-gated SWM gossip when no local allowed signing key exists', async () => { + const agent = await DKGAgent.create({ + name: 'SwmGatedNoSigner', + chainAdapter: new MockChainAdapter(), + }); + const internals = agent as unknown as DKGAgentInternals; + + const localRecord = agentFromPrivateKey(ethers.Wallet.createRandom().privateKey, 'local'); + const remoteAllowed = ethers.Wallet.createRandom(); + internals.localAgents.set(localRecord.agentAddress, localRecord); + internals.defaultAgentAddress = localRecord.agentAddress; + + const contextGraphId = 'gated-swm-cg-no-local-signer'; + await insertAgentGate(agent, contextGraphId, DKG_ONTOLOGY.DKG_ALLOWED_AGENT, remoteAllowed.address); + + const payload = new TextEncoder().encode('gated payload without a local signer'); + await expect(internals.encodeWorkspaceGossipMessage(contextGraphId, payload)) + .rejects.toThrow(/no local allowed signing agent key/); + }); + + it('keeps legacy raw SWM gossip for open graphs when no local signing key exists', async () => { + const agent = await DKGAgent.create({ + name: 'SwmOpenNoSigner', + chainAdapter: new MockChainAdapter(), + }); + const internals = agent as unknown as DKGAgentInternals; + + const contextGraphId = 'open-swm-cg-no-signer'; + const payload = new TextEncoder().encode('legacy raw shared-memory payload'); + const wireMessage = await internals.encodeWorkspaceGossipMessage(contextGraphId, payload); + + expect(Array.from(wireMessage)).toEqual(Array.from(payload)); }); }); diff --git a/packages/publisher/test/workspace-handler-agent-gate.test.ts b/packages/publisher/test/workspace-handler-agent-gate.test.ts new file mode 100644 index 000000000..9a3952b6c --- /dev/null +++ b/packages/publisher/test/workspace-handler-agent-gate.test.ts @@ -0,0 +1,154 @@ +import { beforeEach, describe, expect, it } from 'vitest'; +import { ethers } from 'ethers'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + TypedEventBus, + computeGossipSigningPayload, + contextGraphDataUri, + contextGraphMetaUri, + contextGraphSharedMemoryUri, + DKG_ONTOLOGY, + encodeGossipEnvelope, + encodeWorkspacePublishRequest, + GOSSIP_ENVELOPE_VERSION, + GOSSIP_TYPE_WORKSPACE_PUBLISH, +} from '@origintrail-official/dkg-core'; +import { SharedMemoryHandler } from '../src/index.js'; + +const CONTEXT_GRAPH_ID = 'workspace-handler-agent-gate'; +const DATA_GRAPH = contextGraphDataUri(CONTEXT_GRAPH_ID); +const META_GRAPH = contextGraphMetaUri(CONTEXT_GRAPH_ID); +const WORKSPACE_GRAPH = contextGraphSharedMemoryUri(CONTEXT_GRAPH_ID); +const PEER_ID = '12D3KooWAgentGatePeer'; +const ENTITY = 'urn:test:workspace-handler-agent-gate'; + +let store: OxigraphStore; +let workspaceOwned: Map>; +let handler: SharedMemoryHandler; + +function workspaceMessage(name: string, operationId: string): Uint8Array { + return encodeWorkspacePublishRequest({ + paranetId: CONTEXT_GRAPH_ID, + nquads: new TextEncoder().encode( + `<${ENTITY}> "${name}" <${DATA_GRAPH}> .`, + ), + manifest: [{ rootEntity: ENTITY, privateTripleCount: 0 }], + publisherPeerId: PEER_ID, + workspaceOperationId: operationId, + timestampMs: Date.now(), + }); +} + +async function signWorkspaceMessage(wallet: ethers.Wallet, payload: Uint8Array): Promise { + const timestamp = new Date().toISOString(); + const signingPayload = computeGossipSigningPayload( + GOSSIP_TYPE_WORKSPACE_PUBLISH, + CONTEXT_GRAPH_ID, + timestamp, + payload, + ); + const signature = await wallet.signMessage(signingPayload); + return encodeGossipEnvelope({ + version: GOSSIP_ENVELOPE_VERSION, + type: GOSSIP_TYPE_WORKSPACE_PUBLISH, + contextGraphId: CONTEXT_GRAPH_ID, + agentAddress: wallet.address, + timestamp, + signature: ethers.getBytes(signature), + payload, + }); +} + +async function insertAgentGate(predicate: string, agentAddress: string): Promise { + await store.insert([{ + subject: DATA_GRAPH, + predicate, + object: `"${agentAddress}"`, + graph: META_GRAPH, + }]); +} + +async function expectStoredName(name: string): Promise { + const result = await store.query( + `SELECT ?o WHERE { GRAPH <${WORKSPACE_GRAPH}> { <${ENTITY}> ?o } }`, + ); + expect(result.type).toBe('bindings'); + if (result.type === 'bindings') { + expect(result.bindings).toHaveLength(1); + expect(result.bindings[0]?.['o']).toBe(`"${name}"`); + } +} + +async function expectWorkspaceEmpty(): Promise { + const result = await store.query( + `ASK { GRAPH <${WORKSPACE_GRAPH}> { <${ENTITY}> ?p ?o } }`, + ); + expect(result.type).toBe('boolean'); + if (result.type === 'boolean') { + expect(result.value).toBe(false); + } +} + +describe('SharedMemoryHandler agent-gated gossip', () => { + beforeEach(() => { + store = new OxigraphStore(); + workspaceOwned = new Map(); + handler = new SharedMemoryHandler(store, new TypedEventBus(), { + sharedMemoryOwnedEntities: workspaceOwned, + }); + }); + + it('accepts legacy raw SWM gossip for non-agent-gated context graphs', async () => { + await handler.handle(workspaceMessage('Raw Open', 'ws-agent-gate-raw-open'), PEER_ID); + + await expectStoredName('Raw Open'); + expect(workspaceOwned.get(CONTEXT_GRAPH_ID)?.get(ENTITY)).toBe(PEER_ID); + }); + + it.each([ + ['DKG_ALLOWED_AGENT', DKG_ONTOLOGY.DKG_ALLOWED_AGENT], + ['DKG_PARTICIPANT_AGENT', DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT], + ])('rejects legacy raw SWM gossip for %s-gated context graphs', async (_label, predicate) => { + const allowed = ethers.Wallet.createRandom(); + await insertAgentGate(predicate, allowed.address); + + await handler.handle(workspaceMessage('Unsigned Gated', `ws-agent-gate-raw-${_label}`), PEER_ID); + + await expectWorkspaceEmpty(); + }); + + it.each([ + ['DKG_ALLOWED_AGENT', DKG_ONTOLOGY.DKG_ALLOWED_AGENT], + ['DKG_PARTICIPANT_AGENT', DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT], + ])('accepts signed SWM gossip from a %s writer', async (label, predicate) => { + const allowed = ethers.Wallet.createRandom(); + handler = new SharedMemoryHandler(store, new TypedEventBus(), { + sharedMemoryOwnedEntities: workspaceOwned, + localAgentAddresses: () => [allowed.address], + }); + await insertAgentGate(predicate, allowed.address); + + const raw = workspaceMessage(`${label} Signed`, `ws-agent-gate-signed-${label}`); + await handler.handle(await signWorkspaceMessage(allowed, raw), PEER_ID); + + await expectStoredName(`${label} Signed`); + }); + + it.each([ + ['DKG_ALLOWED_AGENT', DKG_ONTOLOGY.DKG_ALLOWED_AGENT], + ['DKG_PARTICIPANT_AGENT', DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT], + ])('rejects signed SWM gossip from an unauthorized %s writer', async (_label, predicate) => { + const allowed = ethers.Wallet.createRandom(); + const denied = ethers.Wallet.createRandom(); + handler = new SharedMemoryHandler(store, new TypedEventBus(), { + sharedMemoryOwnedEntities: workspaceOwned, + localAgentAddresses: () => [allowed.address], + }); + await insertAgentGate(predicate, allowed.address); + + const raw = workspaceMessage('Denied Signed', `ws-agent-gate-denied-${_label}`); + await handler.handle(await signWorkspaceMessage(denied, raw), PEER_ID); + + await expectWorkspaceEmpty(); + }); +}); From cf22f7ad8668be22b3bb1b59f35d7a2aa2920001 Mon Sep 17 00:00:00 2001 From: Viktor Pelle Date: Tue, 5 May 2026 12:28:03 +0200 Subject: [PATCH 4/4] integrate and document change --- ARCHITECTURE.md | 2 +- README.md | 5 ++++ packages/agent/README.md | 3 ++- packages/core/src/proto/gossip-envelope.ts | 8 ++++-- packages/publisher/README.md | 4 +-- packages/publisher/src/workspace-handler.ts | 30 ++++++++++----------- 6 files changed, 30 insertions(+), 22 deletions(-) diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index a964de85c..b9cc27200 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -128,7 +128,7 @@ rejects writers outside the allowed or participant agent set. sequenceDiagram actor Writer as Local agent process participant Agent as DKGAgent -participant Keys as LocalAgentKeys +participant Keys as AgentKeyStore participant Meta as ContextGraphMeta participant Gossip as GossipSub participant Handler as SharedMemoryHandler diff --git a/README.md b/README.md index ccb7fc7af..2e8714bcf 100644 --- a/README.md +++ b/README.md @@ -57,6 +57,11 @@ create assertion ──► write triples ──► promote ──► publish ─ All on-chain publishing goes through SWM first — the chain transaction is a finality signal that seals data peers already hold via gossip. Assertions themselves carry a durable lifecycle record (`created → promoted → published → finalized`, or `discarded`) in the context graph's `_meta` graph, so their history is auditable independently of the data. +SWM gossip is signed when the node has a local agent private key. Context graphs +that declare `DKG_ALLOWED_AGENT` or `DKG_PARTICIPANT_AGENT` require a signed +`GossipEnvelope` from one of those agent addresses; unsigned legacy SWM payloads +are accepted only for context graphs without agent gates. + --- ## Quick Start diff --git a/packages/agent/README.md b/packages/agent/README.md index 291fc4801..5213c7442 100644 --- a/packages/agent/README.md +++ b/packages/agent/README.md @@ -1,6 +1,6 @@ # @origintrail-official/dkg-agent -Agent runtime for DKG V9. Provides the `DKGAgent` class — the primary entry point for building agents that participate in the decentralized knowledge network. +Agent runtime for DKG V10. Provides the `DKGAgent` class — the primary entry point for building agents that participate in the decentralized knowledge network. ## Features @@ -8,6 +8,7 @@ Agent runtime for DKG V9. Provides the `DKGAgent` class — the primary entry po - **Wallet management** — `DKGAgentWallet` for Ed25519 (P2P identity) and ECDSA (on-chain signing) key pairs, with persistent key storage and operational wallet support - **Agent profiles** — `ProfileManager` for publishing and updating agent skill profiles to the agent registry paranet - **Discovery** — `DiscoveryClient` for finding other agents by name, skill keywords, or semantic search over published profiles +- **Signed shared memory gossip** — SWM writes are wrapped in a signed gossip envelope when a local agent key is available, and agent-gated context graphs require a local `DKG_ALLOWED_AGENT` or `DKG_PARTICIPANT_AGENT` signing key before broadcasting - **Encrypted messaging** — Ed25519-to-X25519 key conversion, ECDH shared secrets, and encrypted P2P message channels - **Skill invocation** — `MessageHandler` for receiving and responding to skill requests; `SkillHandler` and `ChatHandler` for registering custom capabilities diff --git a/packages/core/src/proto/gossip-envelope.ts b/packages/core/src/proto/gossip-envelope.ts index 8484f6b3e..a79e1d28a 100644 --- a/packages/core/src/proto/gossip-envelope.ts +++ b/packages/core/src/proto/gossip-envelope.ts @@ -15,13 +15,17 @@ import protobuf from 'protobufjs'; const { Type, Field } = protobuf; /** - * V10 GossipSub message envelope. + * V10 GossipSub authentication envelope. * - * All GossipSub messages are wrapped in this envelope which provides: + * GossipSub protocols that can authenticate an agent writer wrap the payload + * in this envelope, which provides: * - Protocol version ("10.0.0") * - Message type discrimination * - Context graph binding * - Agent identity and signature for authentication + * + * Legacy raw SWM payloads remain valid only for non-agent-gated context graphs + * when no local signing key is available. */ export const GossipEnvelopeSchema = new Type('GossipEnvelope') diff --git a/packages/publisher/README.md b/packages/publisher/README.md index f335ad65e..2573c914c 100644 --- a/packages/publisher/README.md +++ b/packages/publisher/README.md @@ -1,12 +1,12 @@ # @origintrail-official/dkg-publisher -Publishing protocol for DKG V9. Handles the complete lifecycle of getting Knowledge Assets from a node into the network — from RDF processing through Merkle tree construction to on-chain finalization. +Publishing protocol for DKG V10. Handles the complete lifecycle of getting Knowledge Assets from a node into the network — from RDF processing through Merkle tree construction to on-chain finalization. ## Features - **DKGPublisher** — high-level publishing API: submit RDF, get back a finalized Knowledge Collection UAL - **PublishHandler** — P2P protocol handler that processes incoming publish requests from other nodes, validates data, stores triples, and returns signed ACKs -- **WorkspaceHandler** — feeless "workspace mode" publishing for local-only or staging workflows +- **WorkspaceHandler** — feeless Shared Working Memory handling for local-only or staging workflows, including signed gossip envelope verification for agent-gated context graphs - **Context Graphs** — `createContextGraph` and `publishToContextGraph` for M/N signature-gated subgraphs within paranets - **Context Oracle** — `ContextOracle` class providing verifiable read operations on Context Graphs: `queryWithProofs` (SPARQL with Merkle inclusion proofs), `entityWithProofs` (entity lookup with proofs), and `proveTriple` (single triple existence proof). Provenance triples are scoped to subjects discovered in the query results for efficiency. - **Merkle trees** — per-KA triple hashing, public/private sub-roots, and collection-level Merkle root computation diff --git a/packages/publisher/src/workspace-handler.ts b/packages/publisher/src/workspace-handler.ts index 95ed0b861..94aa9ea38 100644 --- a/packages/publisher/src/workspace-handler.ts +++ b/packages/publisher/src/workspace-handler.ts @@ -1,7 +1,7 @@ import type { TripleStore, Quad } from '@origintrail-official/dkg-storage'; import { GraphManager } from '@origintrail-official/dkg-storage'; import type { EventBus } from '@origintrail-official/dkg-core'; -import { Logger, createOperationContext, contextGraphDataUri, contextGraphMetaUri } from '@origintrail-official/dkg-core'; +import { Logger, createOperationContext, contextGraphDataUri, contextGraphMetaUri, DKG_ONTOLOGY } from '@origintrail-official/dkg-core'; import type { PhaseCallback } from './publisher.js'; import { decodeGossipEnvelope, @@ -160,9 +160,9 @@ export class SharedMemoryHandler { return; } - const allowedAgents = await this.getContextGraphAllowedAgents(contextGraphId); - if (allowedAgents !== null) { - const verified = await this.verifyAgentEnvelope(envelope, payload, contextGraphId, allowedAgents, ctx); + const agentGateAddresses = await this.getContextGraphAgentGateAddresses(contextGraphId); + if (agentGateAddresses !== null) { + const verified = await this.verifyAgentEnvelope(envelope, payload, contextGraphId, agentGateAddresses, ctx); if (!verified) return; } @@ -373,7 +373,7 @@ export class SharedMemoryHandler { envelope: GossipEnvelopeMsg | undefined, payload: Uint8Array, contextGraphId: string, - allowedAgents: string[], + agentGateAddresses: string[], ctx: import('@origintrail-official/dkg-core').OperationContext, ): Promise { if (!envelope) { @@ -421,15 +421,15 @@ export class SharedMemoryHandler { return false; } - const allowedSet = new Set(allowedAgents.map((agent) => agent.toLowerCase())); - if (!allowedSet.has(recovered.toLowerCase())) { + const agentGateSet = new Set(agentGateAddresses.map((agent) => agent.toLowerCase())); + if (!agentGateSet.has(recovered.toLowerCase())) { this.log.warn(ctx, `SWM write rejected: agent ${recovered} is not allowed for context graph "${contextGraphId}"`); return false; } if (this.localAgentAddresses) { const localAgents = await this.localAgentAddresses(); - const localAllowed = localAgents.some((agent) => allowedSet.has(agent.toLowerCase())); + const localAllowed = localAgents.some((agent) => agentGateSet.has(agent.toLowerCase())); if (!localAllowed) { this.log.warn(ctx, `SWM write rejected: local node has no allowed agent for context graph "${contextGraphId}"`); return false; @@ -460,20 +460,18 @@ export class SharedMemoryHandler { } /** - * Returns the agent-address allowlist for a context graph, or null if the - * graph is not agent-gated. Includes local allowedAgent entries and - * on-chain participantAgent metadata. + * Returns the accepted SWM writer agent addresses for a context graph, or + * null if the graph is not agent-gated. Includes DKG_ALLOWED_AGENT and + * DKG_PARTICIPANT_AGENT metadata. */ - private async getContextGraphAllowedAgents(contextGraphId: string): Promise { - const DKG_ALLOWED_AGENT = 'https://dkg.network/ontology#allowedAgent'; - const DKG_PARTICIPANT_AGENT = 'https://dkg.network/ontology#participantAgent'; + private async getContextGraphAgentGateAddresses(contextGraphId: string): Promise { const cgMeta = contextGraphMetaUri(contextGraphId); const cgData = contextGraphDataUri(contextGraphId); const result = await this.store.query( `SELECT ?agent WHERE { GRAPH <${cgMeta}> { - { <${cgData}> <${DKG_ALLOWED_AGENT}> ?agent } + { <${cgData}> <${DKG_ONTOLOGY.DKG_ALLOWED_AGENT}> ?agent } UNION - { <${cgData}> <${DKG_PARTICIPANT_AGENT}> ?agent } + { <${cgData}> <${DKG_ONTOLOGY.DKG_PARTICIPANT_AGENT}> ?agent } } }`, ); if (result.type !== 'bindings' || result.bindings.length === 0) {