Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 5 additions & 1 deletion packages/agent/src/dkg-agent-crypto.ts
Original file line number Diff line number Diff line change
Expand Up @@ -387,7 +387,11 @@ export class WorkspaceCryptoMethods extends DKGAgentBase {
for (const record of this.localAgents.values()) {
if (!record.privateKey) continue;
const signingRecord = { ...record, privateKey: record.privateKey };
if (defaultAddress && record.agentAddress.toLowerCase() === defaultAddress) {
// GH #787 — a node-level key record can carry a privateKey but no
// agentAddress (operational identity, not an agent). Guard the compare so
// it falls through to the fallback signer instead of throwing TypeError
// (`toLowerCase` of undefined → HTTP 500 on every SWM write via that token).
if (defaultAddress && record.agentAddress?.toLowerCase() === defaultAddress) {
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
return signingRecord;
}
fallback ??= signingRecord;
Comment thread
Bojan131 marked this conversation as resolved.
Expand Down
83 changes: 58 additions & 25 deletions packages/agent/src/dkg-agent-swm-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -678,6 +678,38 @@ export class SwmHostModeMethods extends DKGAgentBase {
}
}

/**
* GH #1124 — DEFINITIVE public-CG check for the host-mode ingest gates. Unlike
* `!isCuratedForHostMode` (which treats UNKNOWN as public), this returns true
* ONLY for a CG we can positively confirm is public (open). Curated AND unknown
* both return false, so an in-flight chain-event race (policy not loaded yet)
* keeps the conservative ciphertext+allowlist gates and heals via member
* catchup — it can NEVER misclassify a curated CG as public and admit an
* unauthenticated plaintext envelope into curated storage.
*/
async isConfirmedPublicForHostMode(this: DKGAgent, contextGraphId: string): Promise<boolean> {
// Resolve via the SHARED on-chain policy resolver rather than a direct
// cleartext `subscribedContextGraphs` lookup. `getContextGraphOnChainPolicy`
// re-keys cleartext↔on-chain-id (via subscribedContextGraphs OR
// getContextGraphOnChainId), consults the accessPolicy cache + local `_meta`,
// AND falls back to a direct chain RPC — so it resolves the policy even for a
// host-only core whose subscription is keyed by the wire HASH and that has no
// local `_meta` (the exact #1124 sharded topology). A cleartext-only
// subscribedContextGraphs probe would miss that entry and wrongly drop the
// public envelope.
//
// accessPolicy === 0 is the ONLY confirmed-public answer. Curated (1) and
// unknown (undefined) both → false — the safe bias: keep the ciphertext +
// allowlist gates so a curated CG mid chain-event race is never misclassified
// as public; it heals via member catchup once the policy resolves.
try {
const { accessPolicy } = await this.getContextGraphOnChainPolicy(contextGraphId);
return accessPolicy === 0;
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
} catch {
return false;
}
}

/**
* Register the host-mode gossip handler for `contextGraphId` and
* track its reference so {@link unwireSwmHostModeHandler} can
Expand Down Expand Up @@ -1009,33 +1041,35 @@ 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 ONCE
// (key-independent — see isConfirmedPublicForHostMode) and reuse it for both
// the plaintext gate and the authority check. UNKNOWN CGs stay on the drop
// path (safe; member catchup heals once the policy resolves).
const confirmedPublic = await this.isConfirmedPublicForHostMode(storageCgId);
if (!isCiphertext && !confirmedPublic) return;

// Authority check. Curated traffic verifies the envelope signature against
// the CG's agent allowlist. A confirmed-public CG has no allowlist, so pass
// `allowSelfSignedForPublicCg`: the SHARED verifier then validates signature
// + timestamp-freshness (the replay/eviction guard) AND binds the inner
// request to THIS CG — same envelope validation as curated, only the
// allowlist decision diverges (see SharedMemoryHandler.verifyHostModeEnvelopeAuthority).
//
// Use `storageCgId` (cleartext from the envelope) so the
// member-side meta-graph + chain-fallback resolvers in
// `verifyHostModeEnvelopeAuthority` work on the canonical id
// shape. The hash subscription key is internal bookkeeping;
// never crosses an external authorization boundary.
// Use `storageCgId` (cleartext from the envelope) so the meta-graph +
// chain-fallback resolvers work on the canonical id shape.
const handler = this.getOrCreateSharedMemoryHandler();
const verdict = await handler.verifyHostModeEnvelopeAuthority(data, storageCgId, fromPeerId);
const verdict = await handler.verifyHostModeEnvelopeAuthority(
data, storageCgId, fromPeerId, { allowSelfSignedForPublicCg: confirmedPublic },
);
if (!verdict.accepted) {
// "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,
Expand Down Expand Up @@ -1112,7 +1146,6 @@ export class SwmHostModeMethods extends DKGAgentBase {
}
}
}

const seqno = await this.swmHostModeStore.append(storageCgId, data);
Comment thread
Bojan131 marked this conversation as resolved.
this.log.debug(
ctx,
Expand Down
177 changes: 177 additions & 0 deletions packages/agent/test/swm/host-mode-public-ingest-1124.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
/**
* 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 } from '@origintrail-official/dkg-core';
import { DKGAgent, agentFromPrivateKey, type AgentKeyRecord } from '../../src/index.js';
import { SwmHostModeStore } from '../../src/swm/host-mode-store.js';

interface ClassifierInternals {
isConfirmedPublicForHostMode(cgId: string): Promise<boolean>;
getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>;
}

interface IngestInternals {
getContextGraphOnChainPolicy(cgId: string): Promise<{ accessPolicy?: number; publishPolicy?: number }>;
encodeWorkspaceGossipMessage(contextGraphId: string, message: Uint8Array): Promise<Uint8Array>;
ingestSwmHostModeEnvelope(contextGraphId: string, data: Uint8Array, fromPeerId: string): Promise<void>;
swmHostModeStore?: SwmHostModeStore;
localAgents: Map<string, AgentKeyRecord>;
defaultAgentAddress?: string;
getSwmHostModeStats(): Promise<{ perCg?: Record<string, { entries: number; bytes: number }> } | 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<DKGAgent> {
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('on-chain accessPolicy === 0 → public', async () => {
const g = (await makeCore()) as unknown as ClassifierInternals;
g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 0 });
expect(await g.isConfirmedPublicForHostMode('cg')).toBe(true);
});

it('on-chain accessPolicy === 1 (curated) → NOT public', async () => {
const g = (await makeCore()) as unknown as ClassifierInternals;
g.getContextGraphOnChainPolicy = async () => ({ accessPolicy: 1 });
expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false);
});

it('UNKNOWN policy (accessPolicy undefined — chain-event race) → NOT public (the misclassification guard)', async () => {
const g = (await makeCore()) as unknown as ClassifierInternals;
g.getContextGraphOnChainPolicy = async () => ({}); // unresolved
expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false);
});

it('policy resolver THROWS → NOT public (fail-safe)', async () => {
const g = (await makeCore()) as unknown as ClassifierInternals;
g.getContextGraphOnChainPolicy = async () => { throw new Error('chain unavailable'); };
expect(await g.isConfirmedPublicForHostMode('cg')).toBe(false);
});
});

describe('GH #1124 — ingestSwmHostModeEnvelope gate behaviour (signed plaintext gossip end-to-end)', () => {
const tempDirs: string[] = [];
const agents: DKGAgent[] = [];
afterEach(async () => {
await Promise.all(agents.splice(0).map((a) => a.stop().catch(() => {}).then(() => a.store.close().catch(() => {}))));
await Promise.all(tempDirs.splice(0).map((d) => rm(d, { recursive: true, force: true })));
});

async function makeHostCore(): Promise<DKGAgent> {
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('<urn:p01124:s> <http://schema.org/name> "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<number> {
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 }); // resolves public
const env = await g.encodeWorkspaceGossipMessage(cg, plaintextRequest(cg));
await g.ingestSwmHostModeEnvelope(cg, env, PEER);
expect(await entriesFor(g, cg)).toBe(1);
});

it('CURATED (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 });
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 });
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 }); // both public
// Envelope is signed for CG-A but its inner WorkspacePublishRequest targets CG-B.
const env = await g.encodeWorkspaceGossipMessage(cgEnvelope, plaintextRequest(cgInner));
await g.ingestSwmHostModeEnvelope(cgEnvelope, env, PEER);
expect(await entriesFor(g, cgEnvelope)).toBe(0);
expect(await entriesFor(g, cgInner)).toBe(0);
});
});
20 changes: 20 additions & 0 deletions packages/cli/src/daemon/http-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 `"<s> <p> <o> ."`) 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<string, unknown>;
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[],
Expand Down
6 changes: 6 additions & 0 deletions packages/cli/src/daemon/routes/knowledge-assets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import {
validateOptionalSubGraphName,
validateRequiredContextGraphId,
parsePublishRequestBody,
isWritableQuad,
normalizeContextGraphIdOrUri,
resolveRequiredWriteContextGraphId,
} from "../http-utils.js";
Expand Down Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions packages/cli/src/daemon/routes/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -197,6 +197,7 @@ import {
import {
resolveNameToPeerId,
isPublishQuad,
isWritableQuad,
parsePublishRequestBody,
jsonResponse,
safeDecodeURIComponent,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down
Loading
Loading