diff --git a/packages/agent/src/dkg-agent-lifecycle.ts b/packages/agent/src/dkg-agent-lifecycle.ts index 01277e7c4..307dee87a 100644 --- a/packages/agent/src/dkg-agent-lifecycle.ts +++ b/packages/agent/src/dkg-agent-lifecycle.ts @@ -1510,14 +1510,9 @@ export class LifecycleSyncMethods extends DKGAgentBase { // is actually possible (chain + ordinal reads present). onKARegisteredToContextGraph: this.vmReconcileEnabled() ? async ({ contextGraphId: onChainId, kaId }) => { - const localCgId = this.resolveLocalCgIdByOnChainId(BigInt(onChainId)); - if (!localCgId) return; // chain replay hasn't resolved the cleartext CG yet; sweep heals it - const sub = this.subscribedContextGraphs.get(localCgId); - // Populate VM for CGs we member-subscribe to OR (Phase D) public - // CGs this Core hosts — a hosted Core fills its own gaps too. - if (!sub?.subscribed && !sub?.coreHosted) return; - this.log.info(ctx, `Phase B: KACG nudge cg=${onChainId} ka=${kaId} -> reconcile "${localCgId}"`); - if (this.reconcileCoalescer) void this.reconcileCoalescer.trigger(localCgId); + // GH #1098 — body extracted to `handleKARegisteredNudge` so the + // bind-only-the-matching-CG branch is directly testable. + await this.handleKARegisteredNudge(onChainId, kaId, ctx); } : undefined, }); diff --git a/packages/agent/src/dkg-agent-swm-host.ts b/packages/agent/src/dkg-agent-swm-host.ts index b754ad61f..be44828d3 100644 --- a/packages/agent/src/dkg-agent-swm-host.ts +++ b/packages/agent/src/dkg-agent-swm-host.ts @@ -2348,12 +2348,113 @@ export class SwmHostModeMethods extends DKGAgentBase { async runVmReconcileSweep(this: DKGAgent): Promise { if (!this.vmReconcileEnabled() || !this.reconcileCoalescer) return; for (const [localCgId, sub] of this.subscribedContextGraphs) { + // GH #1098 — self-prime onChainId for a pre-subscribed PUBLIC member CG + // (subscribed BEFORE its first publish, so unbound) before the skip-gate + // below would pass it over. Shared with the live KACG nudge. + if (sub.subscribed && !sub.onChainId) { + await this.selfPrimeSubscriptionOnChainId(localCgId, sub); + } // Member subscriptions AND Phase D core-hosted public CGs get swept. if ((!sub.subscribed && !sub.coreHosted) || !sub.onChainId) continue; void this.reconcileCoalescer.trigger(localCgId); } } + /** + * GH #1098 — bind `sub.onChainId` for a subscribed-but-unbound CG from the + * locally-resolvable OnChainId quad (publisher ontology broadcast / durable + * _meta sync), then persist. The chain `ContextGraphCreated` handler only + * binds CURATED CGs and the ACK-signer hook only fires for cores in a + * publish's storage-ACK set, so a pre-subscribed PUBLIC member would otherwise + * stay unbound — stranded on the unreliable one-shot finalization gossip. + * SHARED by the periodic sweep and the live KACG nudge so the bind / persist / + * cursor-reset semantics (in {@link bindSubscriptionOnChainId}) live in ONE + * place. `targetOnChainId`: when set (the nudge), bind only if the resolved id + * matches THIS event; when omitted (the sweep), bind any non-null id — + * `getContextGraphOnChainId` never falls back to `localCgId`, so a + * `resolved === localCgId` match is legitimate for a direct CG. Best-effort: + * a store/RPC hiccup yields null instead of throwing. Returns the bound id. + */ + async selfPrimeSubscriptionOnChainId( + this: DKGAgent, + localCgId: string, + sub: ContextGraphSub, + targetOnChainId?: bigint, + ): Promise { + if (!sub.subscribed || sub.onChainId) return null; + let resolved: string | null = null; + try { + resolved = await this.getContextGraphOnChainId(localCgId); + } catch { + return null; + } + if (!resolved) return null; + if (targetOnChainId !== undefined) { + let resolvedNum: bigint | null = null; + try { resolvedNum = BigInt(resolved); } catch { return null; } + if (resolvedNum !== targetOnChainId) return null; + } + this.bindSubscriptionOnChainId(localCgId, sub, resolved); + this.persistContextGraphSubscription(localCgId); + return resolved; + } + + /** + * GH #1098 (Phase B) — body of the live `onKARegisteredToContextGraph` nudge, + * extracted so the branch is directly testable. A + * `KnowledgeAssetRegisteredToContextGraph` event carries only `{ kaId, cgId }` + * (no ordinal), so this just triggers a coalesced reconcile for the matching + * local CG. Two cases: + * + * 1. The on-chain id is already bound to a local CG → trigger its reconcile + * (when subscribed or core-hosted). + * 2. The id is unbound but a pre-subscribed PUBLIC member CG resolves to it + * (subscribed BEFORE its first publish; only curated CGs bind on the + * ContextGraphCreated event and ACK-signers bind via the storage-ACK hook) + * → self-prime + bind ONLY the CG whose resolved id matches THIS event, + * then reconcile it. Unrelated subscribed-unbound CGs are left untouched. + * + * Best-effort and idempotent: a missed nudge heals on the periodic sweep. + * Returns the local CG id that was reconciled, or null if none matched. + */ + async handleKARegisteredNudge( + this: DKGAgent, + onChainId: string, + kaId: bigint, + ctx: OperationContext, + ): Promise { + let targetOnChain: bigint | null = null; + try { targetOnChain = BigInt(onChainId); } catch { targetOnChain = null; } + + const localCgId = targetOnChain === null ? null : this.resolveLocalCgIdByOnChainId(targetOnChain); + if (!localCgId) { + // Find the subscribed-but-unbound CG whose locally-resolved on-chain id + // matches THIS event and bind + reconcile only it — targeted, not a global + // sweep, so an unrelated KA registration touches nothing. Uses the SAME + // self-prime helper as the periodic sweep (single bind/persist/cursor-reset + // path); the sweep remains the safety net for a CG whose quad hasn't arrived. + if (targetOnChain !== null) { + for (const [lcg, sub] of this.subscribedContextGraphs) { + const bound = await this.selfPrimeSubscriptionOnChainId(lcg, sub, targetOnChain); + if (bound) { + this.log.info(ctx, `Phase B: KACG nudge cg=${onChainId} ka=${kaId} -> bound + reconcile pre-subscribed "${lcg}"`); + if (this.reconcileCoalescer) void this.reconcileCoalescer.trigger(lcg); + return lcg; + } + } + } + return null; // chain replay hasn't resolved the cleartext CG yet; periodic sweep is the safety net + } + + const sub = this.subscribedContextGraphs.get(localCgId); + // Populate VM for CGs we member-subscribe to OR (Phase D) public CGs this + // Core hosts — a hosted Core fills its own gaps too. + if (!sub?.subscribed && !sub?.coreHosted) return null; + this.log.info(ctx, `Phase B: KACG nudge cg=${onChainId} ka=${kaId} -> reconcile "${localCgId}"`); + if (this.reconcileCoalescer) void this.reconcileCoalescer.trigger(localCgId); + return localCgId; + } + /** * One reconcile pass for a single CG: build the injected deps and hand off to * the pure {@link reconcileContextGraph} orchestrator (which owns the cursor diff --git a/packages/agent/src/finalization-handler.ts b/packages/agent/src/finalization-handler.ts index effa8927a..2c6998795 100644 --- a/packages/agent/src/finalization-handler.ts +++ b/packages/agent/src/finalization-handler.ts @@ -16,7 +16,7 @@ import { GraphManager, type TripleStore, type Quad } from '@origintrail-official import { type ChainAdapter, type EventFilter } from '@origintrail-official/dkg-chain'; import { computeFlatKCRootV10 as computeFlatKCRoot, skolemizeByEntity, - generateConfirmedFullMetadata, getTentativeStatusQuad, + generateConfirmedFullMetadata, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, generateSubGraphRegistration, shouldApplyMaterialization, writeMaterializedVersion, withMaterializationLock, type MaterializedVersion, @@ -1066,8 +1066,18 @@ export class FinalizationHandler { } const kaMetadata: KAMetadata[] = []; - for (let tokenIdx = 0; tokenIdx < rootEntities.length; tokenIdx++) { - const rootEntity = rootEntities[tokenIdx]; + // GH #936 — assign per-root tokenIds over a CANONICAL (lexicographic) root + // order, NOT the SPARQL/gossip binding order. oxigraph binding order is + // store-history-dependent, so two replicas reconciling the same KC from + // chain would otherwise mint divergent root→tokenId maps. These tokenIds are + // local compatibility labels (the on-chain KA count is 1, no on-chain + // dependency — see dkg-publisher.ts), so a content-derived sort makes the + // map a pure function of the root SET: identical on every replica and on + // both the gossip and chain-reconcile promotion paths. + const orderedRoots = [...rootEntities].sort(compareRootIris); + + for (let tokenIdx = 0; tokenIdx < orderedRoots.length; tokenIdx++) { + const rootEntity = orderedRoots[tokenIdx]; const entityQuads = partitioned.get(rootEntity) ?? []; if (entityQuads.length === 0) continue; kaMetadata.push({ @@ -1159,6 +1169,16 @@ export class FinalizationHandler { } catch { /* tentative status may not exist */ } let metaQuads = generateConfirmedFullMetadata(kcMeta, kaMetadata, provenance); + + // GH #936 — append the SHARED deterministic per-root token rows (no-op for + // single-root). This is the SAME helper the publisher uses on the originator + // path, so a locally-published and a chain-reconciled multi-root KC expose + // an identical, queryable rootEntity→tokenId map. graph = the default + // `/_meta` so the ctxGraphId remap below routes them to the per-cgId + // `_meta` (and dual-writes a root copy when keepRootCopyOnLabel). + metaQuads.push( + ...buildDeterministicTokenRows(ual, kaMetadata, `did:dkg:context-graph:${contextGraphId}/_meta`), + ); if (ctxGraphId) { const defaultMeta = `did:dkg:context-graph:${contextGraphId}/_meta`; const targetMeta = contextGraphMetaUri(contextGraphId, ctxGraphId); diff --git a/packages/agent/src/op-wallets.ts b/packages/agent/src/op-wallets.ts index d7995ebe9..3150ef43a 100644 --- a/packages/agent/src/op-wallets.ts +++ b/packages/agent/src/op-wallets.ts @@ -1,6 +1,7 @@ import { ethers } from 'ethers'; import { chmod, readFile, writeFile, mkdir } from 'node:fs/promises'; import { join } from 'node:path'; +import { randomBytes, scryptSync, createCipheriv, createDecipheriv } from 'node:crypto'; export interface WalletEntry { address: string; @@ -16,6 +17,35 @@ export interface OpWalletsConfig { const DEFAULT_WALLET_COUNT = 3; +// GH #11 — operational wallet private keys are encrypted at rest (AES-256-GCM) +// so `wallets.json` never carries a plaintext key. The key is derived from a +// machine-local 32-byte secret in `wallets.key` (zero operator interaction); +// when `DKG_WALLETS_PASSPHRASE` is set it is mixed in via scrypt for an extra +// factor against host-FS compromise. `address` stays plaintext so every +// address-only reader (faucet, openclaw setup, status) keeps working, and +// `loadOpWallets` still returns decrypted keys in memory for the chain config. +const WALLET_SECRET_FILE = 'wallets.key'; +const PASSPHRASE_ENV = 'DKG_WALLETS_PASSPHRASE'; +// Test-only escape hatch: skip the legacy-plaintext→encrypted migration on load. +// Set by harnesses/tooling that read the raw `privateKey` field out of +// wallets.json directly and cannot decrypt (e.g. the devnet staking script). +const NO_MIGRATE_ENV = 'DKG_WALLETS_NO_MIGRATE'; + +interface EncryptedKeystore { + v: number; + alg: 'aes-256-gcm'; + kdf: 'raw' | 'scrypt'; + iv: string; + ct: string; + tag: string; +} + +interface StoredWalletEntry { + address: string; + privateKey?: string; + keystore?: EncryptedKeystore; +} + /** * Load admin + operational wallets from `wallets.json` in the data directory. * Legacy files without `adminWallet` remain readable, but profile @@ -34,7 +64,7 @@ export async function loadOpWallets( try { const raw = await readFile(filePath, 'utf-8'); - const parsed = JSON.parse(raw) as Partial | WalletEntry[]; + const parsed = JSON.parse(raw) as { adminWallet?: StoredWalletEntry; wallets?: StoredWalletEntry[] } | StoredWalletEntry[]; const existingWallets = Array.isArray(parsed) ? parsed : parsed.wallets; if (!Array.isArray(existingWallets)) { throw new Error('wallets.json must contain a wallets array'); @@ -44,12 +74,34 @@ export async function loadOpWallets( } { - const wallets = existingWallets.map((w, index) => - validateWalletEntry(w, `wallets[${index}]`), - ); - const adminWallet = !Array.isArray(parsed) && parsed.adminWallet - ? validateWalletEntry(parsed.adminWallet, 'adminWallet') - : undefined; + const adminStored = !Array.isArray(parsed) ? parsed.adminWallet : undefined; + // Decrypt any keystore entries before validating addresses. The secret is + // only required when at least one entry is encrypted; legacy plaintext + // files load with no secret and are opportunistically re-encrypted below. + const hasEncrypted = existingWallets.some(isEncryptedEntry) + || (adminStored ? isEncryptedEntry(adminStored) : false); + const secret = hasEncrypted ? await loadWalletSecret(dataDir) : undefined; + if (hasEncrypted && !secret) { + throw new Error( + `wallets.json holds encrypted wallet keystores but ${WALLET_SECRET_FILE} is missing — cannot decrypt. ` + + `Restore ${WALLET_SECRET_FILE} (or set ${PASSPHRASE_ENV} if it was passphrase-protected) from backup.`, + ); + } + + let sawLegacyPlaintext = false; + const resolve = (stored: StoredWalletEntry, path: string): WalletEntry => { + if (isEncryptedEntry(stored)) { + const privateKey = decryptKey(stored.keystore!, secret!); + return validateWalletEntry({ address: stored.address, privateKey }, path); + } + // Legacy plaintext entry — accepted (back-compat), and flagged for + // opportunistic migration to an encrypted keystore below. + sawLegacyPlaintext = true; + return validateWalletEntry({ address: stored.address, privateKey: stored.privateKey as string }, path); + }; + + const wallets = existingWallets.map((w, index) => resolve(w, `wallets[${index}]`)); + const adminWallet = adminStored ? resolve(adminStored, 'adminWallet') : undefined; if (adminWallet) { const adminKey = adminWallet.address.toLowerCase(); @@ -60,7 +112,24 @@ export async function loadOpWallets( } } - return { adminWallet, wallets }; + const config = { adminWallet, wallets }; + // GH #11 migration — an upgraded node that still has a LEGACY plaintext + // wallets.json (the deployed wallets most likely to hold real funds) gets + // its keys transparently re-saved as encrypted keystores after a + // successful load (same keys, same addresses — no rotation, no lockout). + // This closes the plaintext-at-rest exposure for existing operators, not + // just fresh installs. Opt OUT via `DKG_WALLETS_NO_MIGRATE=1` for test + // harnesses / provisioning tooling that reads the raw `privateKey` field + // directly (e.g. the devnet staking script) and cannot decrypt. The + // re-save is best-effort: a write failure must not block loading. + if (sawLegacyPlaintext && process.env[NO_MIGRATE_ENV] !== '1') { + try { + await saveOpWallets(dataDir, config); + } catch { + /* keep serving the loaded keys even if the migration re-save fails */ + } + } + return config; } } catch (err: any) { if (err.code !== 'ENOENT') throw err; @@ -85,8 +154,18 @@ export function generateWallets(count: number): OpWalletsConfig { async function saveOpWallets(dataDir: string, config: OpWalletsConfig): Promise { await mkdir(dataDir, { recursive: true }); + const secret = await loadOrCreateWalletSecret(dataDir); + const passphrase = process.env[PASSPHRASE_ENV]; + const encEntry = (w: WalletEntry): StoredWalletEntry => ({ + address: w.address, + keystore: encryptKey(w.privateKey, secret, passphrase), + }); + const stored: { adminWallet?: StoredWalletEntry; wallets: StoredWalletEntry[] } = { + ...(config.adminWallet ? { adminWallet: encEntry(config.adminWallet) } : {}), + wallets: config.wallets.map(encEntry), + }; const filePath = join(dataDir, 'wallets.json'); - await writeFile(filePath, JSON.stringify(config, null, 2) + '\n', { mode: 0o600 }); + await writeFile(filePath, JSON.stringify(stored, null, 2) + '\n', { mode: 0o600 }); await chmod(filePath, 0o600); } @@ -104,3 +183,80 @@ function validateWalletEntry(entry: WalletEntry, path: string): WalletEntry { } return { address: derived.address, privateKey: derived.privateKey }; } + +// ── GH #11 — at-rest encryption helpers ──────────────────────────────────── + +function isEncryptedEntry(entry: StoredWalletEntry): boolean { + return !!entry && typeof entry === 'object' && !!entry.keystore; +} + +/** Derive the 32-byte AES key: the raw machine-local secret, optionally + * strengthened with a scrypt pass over an operator passphrase. */ +function deriveAtRestKey(secret: Buffer, kdf: 'raw' | 'scrypt', passphrase?: string): Buffer { + if (kdf === 'scrypt') { + if (!passphrase) { + throw new Error(`${PASSPHRASE_ENV} is required to derive the wallet key for a passphrase-protected keystore`); + } + return scryptSync(passphrase, secret, 32, { N: 16384, r: 8, p: 1 }); + } + return secret; +} + +function encryptKey(privateKey: string, secret: Buffer, passphrase?: string): EncryptedKeystore { + const kdf: 'raw' | 'scrypt' = passphrase ? 'scrypt' : 'raw'; + const key = deriveAtRestKey(secret, kdf, passphrase); + const iv = randomBytes(12); + const cipher = createCipheriv('aes-256-gcm', key, iv); + const ct = Buffer.concat([cipher.update(privateKey, 'utf8'), cipher.final()]); + const tag = cipher.getAuthTag(); + return { + v: 1, + alg: 'aes-256-gcm', + kdf, + iv: iv.toString('base64'), + ct: ct.toString('base64'), + tag: tag.toString('base64'), + }; +} + +function decryptKey(keystore: EncryptedKeystore, secret: Buffer): string { + const passphrase = process.env[PASSPHRASE_ENV]; + if (keystore.kdf === 'scrypt' && !passphrase) { + throw new Error( + `wallet keystore was encrypted with ${PASSPHRASE_ENV} which is not currently set — refusing to decrypt`, + ); + } + const key = deriveAtRestKey(secret, keystore.kdf, passphrase); + const decipher = createDecipheriv('aes-256-gcm', key, Buffer.from(keystore.iv, 'base64')); + decipher.setAuthTag(Buffer.from(keystore.tag, 'base64')); + const pt = Buffer.concat([decipher.update(Buffer.from(keystore.ct, 'base64')), decipher.final()]); + return pt.toString('utf8'); +} + +/** Read the machine-local wallet secret, or undefined if it doesn't exist. */ +async function loadWalletSecret(dataDir: string): Promise { + try { + const raw = await readFile(join(dataDir, WALLET_SECRET_FILE), 'utf-8'); + const buf = Buffer.from(raw.trim(), 'base64'); + if (buf.length !== 32) { + throw new Error(`${WALLET_SECRET_FILE} is malformed (expected a 32-byte base64 secret)`); + } + return buf; + } catch (err: any) { + if (err.code === 'ENOENT') return undefined; + throw err; + } +} + +/** Read or create the machine-local wallet secret (32 random bytes, mode 0600). + * The secret is independent of any wallet key, so it never leaks a private key. */ +async function loadOrCreateWalletSecret(dataDir: string): Promise { + const existing = await loadWalletSecret(dataDir); + if (existing) return existing; + await mkdir(dataDir, { recursive: true }); + const secret = randomBytes(32); + const secretPath = join(dataDir, WALLET_SECRET_FILE); + await writeFile(secretPath, secret.toString('base64') + '\n', { mode: 0o600 }); + await chmod(secretPath, 0o600); + return secret; +} diff --git a/packages/agent/test/issue-936-tokenid-determinism.test.ts b/packages/agent/test/issue-936-tokenid-determinism.test.ts new file mode 100644 index 000000000..db885b42d --- /dev/null +++ b/packages/agent/test/issue-936-tokenid-determinism.test.ts @@ -0,0 +1,135 @@ +/** + * Issue-liveness repro for GH #936 — "Chain-driven VM reconcile: per-root + * tokenId order is non-deterministic." https://github.com/OriginTrail/dkg/issues/936 + * + * On the chain-driven reconcile path (`handleChainReconciledKC`, no gossip + * `rootEntities` on the wire) the recovered roots come straight from a SPARQL + * read of SWM workspace meta, and each KA's on-chain `tokenId` is then assigned + * POSITIONALLY from that array (`tokenId = index + 1`). The merkle root can't + * pin the order — `V10MerkleTree` sorts+dedupes its leaves, so any root + * permutation verifies. And the SPARQL binding order is store-history-dependent + * (oxigraph returns the SAME triples in DIFFERENT orders depending on insertion + * history — see the two replicas below). + * + * Consequence: two replicas reconciling the SAME knowledge collection from chain + * map the SAME rootEntity to DIFFERENT tokenIds → they disagree on which content + * lives at `/1` vs `/2`. + * + * This test asserts the CORRECT (post-fix) behaviour, so it is RED today + * (the bug is live) and turns GREEN once the fix lands; it stays red until #936 is fixed.. + * Hermetic — two in-memory oxigraph stores, a binding chain stub, no network. + */ +import { describe, it, expect } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { + createOperationContext, + contextGraphWorkspaceGraphUri, + contextGraphWorkspaceMetaGraphUri, +} from '@origintrail-official/dkg-core'; +import type { ChainAdapter } from '@origintrail-official/dkg-chain'; +import { computeFlatKCRootV10 } from '@origintrail-official/dkg-publisher'; +import { FinalizationHandler } from '../src/finalization-handler.js'; + +const CG = 'gh936-cg'; +const ON_CHAIN_CG = '42'; +const UAL = 'did:dkg:evm:31337/0xABC/7'; +const PUBLISHER = '0xf39Fd6e51aad88F6F4ce6aB8827279cffFb92266'; +const KA_ID = 7n; + +// Three roots whose names also feed the (order-insensitive) merkle. +const ROOTS = [ + { iri: 'urn:gh936:zzz', name: '"Zulu"' }, + { iri: 'urn:gh936:aaa', name: '"Alpha"' }, + { iri: 'urn:gh936:mmm', name: '"Mike"' }, +]; + +function makeBindingChain(boundCg: bigint): ChainAdapter { + return { chainId: 'evm:31337', getKAContextGraphId: async () => boundCg } as unknown as ChainAdapter; +} + +/** Seed the 3-root SWM snapshot with the data + op→root meta inserted in + * `insertOrder`. Returns the (order-insensitive) flat-KC merkle root. */ +async function seedSnapshot(store: OxigraphStore, insertOrder: typeof ROOTS): Promise { + const wsGraph = contextGraphWorkspaceGraphUri(CG); + const wsMetaGraph = contextGraphWorkspaceMetaGraphUri(CG); + for (const r of insertOrder) { + await store.insert([ + { subject: r.iri, predicate: 'http://schema.org/name', object: r.name, graph: wsGraph }, + { subject: 'urn:dkg:share:gh936:op-1', predicate: 'http://dkg.io/ontology/rootEntity', object: r.iri, graph: wsMetaGraph }, + ]); + } + // Merkle over all roots' triples (graph stripped), order-insensitive. + return computeFlatKCRootV10( + ROOTS.map((r) => ({ subject: r.iri, predicate: 'http://schema.org/name', object: r.name, graph: '' })), + [], + ); +} + +/** Read the confirmed rootEntity→tokenId mapping from a reconciled replica. + * Reads the PER-ROOT label rows (`/`) only — the aggregate + * `` row carries every member's tokenId AND entity, which would + * cross-product. */ +async function readRootTokenMap(store: OxigraphStore): Promise> { + const metaGraph = `did:dkg:context-graph:${CG}/context/${ON_CHAIN_CG}/_meta`; + const res: any = await store.query( + `SELECT ?ka ?root ?tid WHERE { + GRAPH <${metaGraph}> { + ?ka ?tid . + ?ka ?root . + FILTER(STRSTARTS(STR(?ka), "${UAL}/")) + } + }`, + ); + const map: Record = {}; + if (res.type === 'bindings') { + for (const b of res.bindings) { + const root = String(b.root).replace(/^<|>$/g, ''); + const tid = String(b.tid).replace(/^"/, '').replace(/"(\^\^.*)?$/, ''); + map[root] = tid; + } + } + return map; +} + +async function reconcile(insertOrder: typeof ROOTS): Promise> { + const store = new OxigraphStore(); + const merkleRoot = await seedSnapshot(store, insertOrder); + const handler = new FinalizationHandler(store, makeBindingChain(BigInt(ON_CHAIN_CG))); + const outcome = await handler.handleChainReconciledKC( + { contextGraphId: CG, onChainCgId: ON_CHAIN_CG, ual: UAL, merkleRoot, publisherAddress: PUBLISHER, kaId: KA_ID, versionBlock: 500 }, + createOperationContext('system'), + ); + expect(outcome).toBe('promoted'); + return readRootTokenMap(store); +} + +describe('GH #936 — chain-driven reconcile must map each root to a deterministic tokenId', () => { + it('two replicas reconciling the same KC agree on the rootEntity→tokenId mapping', async () => { + // Replica A and replica B received the same 3 roots in DIFFERENT orders + // (independent share-time histories). oxigraph's SPARQL binding order + // tracks insertion history, so each replica recovers a different root order. + const replicaA = await reconcile([ROOTS[0], ROOTS[1], ROOTS[2]]); // z, a, m + const replicaB = await reconcile([ROOTS[1], ROOTS[2], ROOTS[0]]); // a, m, z + + // Control: each replica produced a full 3-root mapping (so the equality + // assertion below is meaningful, not vacuously comparing empty maps). + expect(Object.keys(replicaA)).toHaveLength(3); + expect(Object.keys(replicaB)).toHaveLength(3); + + // CORRECT (post-fix): the rootEntity→tokenId mapping is content-derived and + // identical on every replica. Today it is positional over a store-dependent + // order, so the two replicas disagree on which root owns `/1`. + expect(replicaA).toEqual(replicaB); + + // Pin the EXACT canonical (lexicographic-by-IRI) map, not just that the two + // replicas agree — otherwise, if oxigraph ever returned the unordered + // bindings already sorted, the old positional code would also produce + // identical maps and this test would be false-green. aaa < mmm < zzz. + const expectedCanonicalMap: Record = { + 'urn:gh936:aaa': '1', + 'urn:gh936:mmm': '2', + 'urn:gh936:zzz': '3', + }; + expect(replicaA).toEqual(expectedCanonicalMap); + }); +}); diff --git a/packages/agent/test/op-wallets-at-rest-encryption.test.ts b/packages/agent/test/op-wallets-at-rest-encryption.test.ts new file mode 100644 index 000000000..87331e91e --- /dev/null +++ b/packages/agent/test/op-wallets-at-rest-encryption.test.ts @@ -0,0 +1,117 @@ +/** + * Liveness/regression test for GH #11 — "Secrets partially unencrypted on disk". + * https://github.com/OriginTrail/dkg/issues/11 + * + * An AES-256-GCM keystore module exists (`packages/cli/src/keystore.ts`) but is + * not wired into the operational-wallet storage path: `loadOpWallets` generates + * wallets and persists them via `saveOpWallets`, which writes + * `JSON.stringify(config)` — including each wallet's raw `privateKey` — to + * `wallets.json` (mode 0o600 but unencrypted). On mainnet those wallets hold + * real TRAC/ETH, so plaintext-at-rest is a real exposure. + * + * This asserts the CORRECT (post-fix) behaviour — no wallet's raw private key + * appears in plaintext ANYWHERE under the data dir — so it is RED while the keys + * are stored plaintext and GREEN once the keystore is wired in. It scans EVERY + * persisted file (not just `wallets.json`) so a valid fix that moves secrets into + * an encrypted keystore, renames the artifact, or leaves only non-secret + * metadata still turns it green (Codex review on PR #1129). Hermetic — tmpdir. + */ +import { describe, expect, it, afterEach } from 'vitest'; +import { mkdtemp, readFile, rm, readdir, writeFile } from 'node:fs/promises'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { ethers } from 'ethers'; +import { loadOpWallets } from '../src/op-wallets.js'; + +/** Every regular file under `dir`, recursively. */ +async function walkFiles(dir: string): Promise { + const out: string[] = []; + for (const entry of await readdir(dir, { withFileTypes: true })) { + const p = join(dir, entry.name); + if (entry.isDirectory()) out.push(...(await walkFiles(p))); + else if (entry.isFile()) out.push(p); + } + return out; +} + +const dirs: string[] = []; +afterEach(async () => { + await Promise.all(dirs.map((d) => rm(d, { recursive: true, force: true }).catch(() => {}))); + dirs.length = 0; +}); + +describe('GH #11 — operational wallet private keys at rest', () => { + it('does not persist any wallet raw private key in plaintext anywhere on disk', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gh11-opwallets-')); + dirs.push(dir); + + const config = await loadOpWallets(dir, 2); // generates + persists on first call + + // Control: the in-memory config really does carry private keys (so the + // negative assertion below is meaningful, not vacuously true). + expect(config.wallets[0].privateKey).toMatch(/^0x[0-9a-fA-F]{64}$/); + expect(config.adminWallet?.privateKey).toMatch(/^0x[0-9a-fA-F]{64}$/); + + // Scan EVERY persisted file (and the bare-hex form, in case a future writer + // strips the 0x prefix). No file may contain a wallet's raw private key — + // including the ADMIN wallet (a regression that encrypts operational wallets + // but leaves the admin key plaintext must fail here too). + const allKeys = [ + ...config.wallets.map((w) => w.privateKey), + ...(config.adminWallet ? [config.adminWallet.privateKey] : []), + ]; + const files = await walkFiles(dir); + const blobs = await Promise.all(files.map((f) => readFile(f, 'utf-8').catch(() => ''))); + const combined = blobs.join('\n'); + for (const hex of allKeys) { + const bare = hex.replace(/^0x/, ''); + expect(combined, `a persisted file under ${dir} contains a raw private key`).not.toContain(hex); + expect(combined).not.toContain(bare); + } + + // Reload from the now-encrypted files (simulates a daemon restart): the + // keystore must decrypt back to the SAME admin + operational keys. Guards + // against writing an unreadable keystore or rotating keys on reload. + const reloaded = await loadOpWallets(dir, 2); + expect(reloaded.adminWallet?.privateKey).toBe(config.adminWallet?.privateKey); + expect(reloaded.adminWallet?.address).toBe(config.adminWallet?.address); + expect(reloaded.wallets.map((w) => w.privateKey)).toEqual(config.wallets.map((w) => w.privateKey)); + expect(reloaded.wallets.map((w) => w.address)).toEqual(config.wallets.map((w) => w.address)); + }); + + it('migrates an existing LEGACY plaintext wallets.json to an encrypted keystore on load', async () => { + const dir = await mkdtemp(join(tmpdir(), 'gh11-migrate-')); + dirs.push(dir); + + // Simulate an upgraded node carrying a pre-encryption plaintext wallets.json + // (the deployed wallets most likely to hold real funds). + const admin = ethers.Wallet.createRandom(); + const op = ethers.Wallet.createRandom(); + await writeFile( + join(dir, 'wallets.json'), + JSON.stringify({ + adminWallet: { address: admin.address, privateKey: admin.privateKey }, + wallets: [{ address: op.address, privateKey: op.privateKey }], + }), + ); + + // Load: accepts the legacy keys AND transparently re-encrypts the file. + const loaded = await loadOpWallets(dir); + expect(loaded.adminWallet?.privateKey).toBe(admin.privateKey); + expect(loaded.wallets[0].privateKey).toBe(op.privateKey); + + // The on-disk file no longer contains either raw private key. + const files = await walkFiles(dir); + const combined = (await Promise.all(files.map((f) => readFile(f, 'utf-8').catch(() => '')))).join('\n'); + for (const hex of [admin.privateKey, op.privateKey]) { + expect(combined, 'legacy plaintext key still on disk after migration').not.toContain(hex); + expect(combined).not.toContain(hex.replace(/^0x/, '')); + } + + // Reload (simulated restart): decrypts back to the SAME keys — no rotation, + // no lockout. + const reloaded = await loadOpWallets(dir); + expect(reloaded.adminWallet?.privateKey).toBe(admin.privateKey); + expect(reloaded.wallets[0].privateKey).toBe(op.privateKey); + }); +}); diff --git a/packages/agent/test/vm-reconcile-self-prime.test.ts b/packages/agent/test/vm-reconcile-self-prime.test.ts new file mode 100644 index 000000000..ce6ffb7ec --- /dev/null +++ b/packages/agent/test/vm-reconcile-self-prime.test.ts @@ -0,0 +1,183 @@ +/** + * Regression test for GH #1098 (layer 1) — the chain-driven VM reconcile sweep + * must SELF-PRIME `onChainId` for a peer that subscribed to a PUBLIC CG BEFORE + * its first publish. Such a peer has `subscribed: true` but no `onChainId` (only + * curated CGs bind it on the ContextGraphCreated event; ACK-signers bind via the + * storage-ACK hook), so the sweep would otherwise skip it forever and the peer + * never reconciles the published KA into VM. + * + * This pins the state transition: a `subscribed && !onChainId` entry whose + * ontology OnChainId quad is locally present gets bound + persisted, and the + * sweep then triggers its reconcile. Hermetic — MockChainAdapter, no network. + */ +import { afterEach, describe, it, expect } from 'vitest'; +import { MockChainAdapter } from '@origintrail-official/dkg-chain'; +import { + DKG_ONTOLOGY, + SYSTEM_CONTEXT_GRAPHS, + contextGraphDataGraphUri, + createOperationContext, +} from '@origintrail-official/dkg-core'; +import type { TripleStore } from '@origintrail-official/dkg-storage'; +import { DKGAgent } from '../src/index.js'; + +interface AgentInternals { + runVmReconcileSweep(): Promise; + selfPrimeSubscriptionOnChainId( + localCgId: string, + sub: { subscribed: boolean; coreHosted?: boolean; onChainId?: string }, + targetOnChainId?: bigint, + ): Promise; + handleKARegisteredNudge(onChainId: string, kaId: bigint, ctx: unknown): Promise; + subscribedContextGraphs: Map; + reconcileCoalescer: { trigger: (cg: string) => void } | null; + store: TripleStore; +} + +// DKGNode getter throws on peerId access without a real start(); stub it so the +// subscription bookkeeping path runs (mirrors core-fills-gap.test.ts). +function stubNode(agent: DKGAgent): void { + (agent as unknown as { node: unknown }).node = { + peerId: '12D3KooWSelfPrimeTestPeer', + libp2p: { getPeers: () => [] }, + }; +} + +describe('GH #1098 — VM reconcile sweep self-primes onChainId for a pre-subscribed CG', () => { + let agent: DKGAgent | null = null; + afterEach(async () => { + if (agent) { await agent.stop().catch(() => undefined); agent = null; } + }); + + it('binds onChainId from the ontology quad, persists, and triggers reconcile for a subscribed-but-unbound CG', async () => { + const chain = new MockChainAdapter(); + agent = await DKGAgent.create({ name: 'SelfPrimeSweep', chainAdapter: chain }); + stubNode(agent); + const internals = agent as unknown as AgentInternals; + + const LOCAL = 'gh1098-presub'; + const ONCHAIN = '4242'; + + // The publisher broadcasts the CG's OnChainId quad on the ontology topic at + // publish time (durable _meta sync also delivers it). Seed it — this is the + // exact source `getContextGraphOnChainId` reads. + await internals.store.insert([{ + subject: `did:dkg:context-graph:${LOCAL}`, + predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, + object: `"${ONCHAIN}"`, + graph: contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY), + }]); + + // The #1098 state: a pre-subscribed member CG with NO onChainId bound. + internals.subscribedContextGraphs.set(LOCAL, { subscribed: true }); + + const triggered: string[] = []; + internals.reconcileCoalescer = { trigger: (cg: string) => { triggered.push(cg); } }; + + // Precondition: unbound before the sweep (so the assertion below is meaningful). + expect(internals.subscribedContextGraphs.get(LOCAL)?.onChainId).toBeUndefined(); + + await internals.runVmReconcileSweep(); + + // Post-fix: the sweep self-primed onChainId from the ontology quad and then + // — no longer skipped by the `!onChainId` guard — triggered its reconcile. + expect(internals.subscribedContextGraphs.get(LOCAL)?.onChainId).toBe(ONCHAIN); + expect(triggered).toContain(LOCAL); + }); + + it('KACG nudge targeting: binds ONLY the unbound CG whose on-chain id matches the event, not an unrelated one', async () => { + // This exercises the SAME `selfPrimeSubscriptionOnChainId` helper the live + // onKARegisteredToContextGraph nudge delegates to, with a `targetOnChainId` + // (the event's CG id). The nudge loops subscribed-unbound CGs and binds the + // one whose resolved id matches the event — so an unrelated CG must NOT bind. + const chain = new MockChainAdapter(); + agent = await DKGAgent.create({ name: 'SelfPrimeTargeted', chainAdapter: chain }); + stubNode(agent); + const internals = agent as unknown as AgentInternals; + + const CG_MATCH = 'gh1098-match'; + const CG_OTHER = 'gh1098-other'; + const ON_MATCH = '500'; + const ON_OTHER = '600'; + const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY); + await internals.store.insert([ + { subject: `did:dkg:context-graph:${CG_MATCH}`, predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, object: `"${ON_MATCH}"`, graph: ontologyGraph }, + { subject: `did:dkg:context-graph:${CG_OTHER}`, predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, object: `"${ON_OTHER}"`, graph: ontologyGraph }, + ]); + internals.subscribedContextGraphs.set(CG_MATCH, { subscribed: true }); + internals.subscribedContextGraphs.set(CG_OTHER, { subscribed: true }); + + // Event for ON_MATCH: the other CG (resolves to ON_OTHER) must NOT bind. + const other = await internals.selfPrimeSubscriptionOnChainId(CG_OTHER, internals.subscribedContextGraphs.get(CG_OTHER)!, BigInt(ON_MATCH)); + expect(other).toBeNull(); + expect(internals.subscribedContextGraphs.get(CG_OTHER)?.onChainId).toBeUndefined(); + + // The matching CG binds. + const matched = await internals.selfPrimeSubscriptionOnChainId(CG_MATCH, internals.subscribedContextGraphs.get(CG_MATCH)!, BigInt(ON_MATCH)); + expect(matched).toBe(ON_MATCH); + expect(internals.subscribedContextGraphs.get(CG_MATCH)?.onChainId).toBe(ON_MATCH); + }); + + it('live KACG nudge handler: with multiple subscribed-unbound CGs, binds + reconciles ONLY the one matching the event id', async () => { + // Exercises the EXACT branch the live `onKARegisteredToContextGraph` poller + // hook runs (extracted to `handleKARegisteredNudge`), not just the underlying + // self-prime helper — so the loop-and-target logic is covered end to end. + // Three pre-subscribed PUBLIC member CGs are unbound (the #1098 state); a KA + // registration arrives for ONE of their on-chain ids. Only that CG must bind + // and reconcile; the other two are left untouched. + const chain = new MockChainAdapter(); + agent = await DKGAgent.create({ name: 'KacgNudgeLive', chainAdapter: chain }); + stubNode(agent); + const internals = agent as unknown as AgentInternals; + + const CG_HIT = 'gh1098-nudge-hit'; + const CG_MISS_A = 'gh1098-nudge-miss-a'; + const CG_MISS_B = 'gh1098-nudge-miss-b'; + const ON_HIT = '7000'; + const ON_MISS_A = '7001'; + const ON_MISS_B = '7002'; + const ontologyGraph = contextGraphDataGraphUri(SYSTEM_CONTEXT_GRAPHS.ONTOLOGY); + await internals.store.insert([ + { subject: `did:dkg:context-graph:${CG_HIT}`, predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, object: `"${ON_HIT}"`, graph: ontologyGraph }, + { subject: `did:dkg:context-graph:${CG_MISS_A}`, predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, object: `"${ON_MISS_A}"`, graph: ontologyGraph }, + { subject: `did:dkg:context-graph:${CG_MISS_B}`, predicate: `${DKG_ONTOLOGY.DKG_CONTEXT_GRAPH}OnChainId`, object: `"${ON_MISS_B}"`, graph: ontologyGraph }, + ]); + internals.subscribedContextGraphs.set(CG_HIT, { subscribed: true }); + internals.subscribedContextGraphs.set(CG_MISS_A, { subscribed: true }); + internals.subscribedContextGraphs.set(CG_MISS_B, { subscribed: true }); + + const triggered: string[] = []; + internals.reconcileCoalescer = { trigger: (cg: string) => { triggered.push(cg); } }; + + // The event names ON_HIT's on-chain id. None is bound yet. + const reconciled = await internals.handleKARegisteredNudge(ON_HIT, 99n, createOperationContext('system')); + + expect(reconciled).toBe(CG_HIT); + expect(internals.subscribedContextGraphs.get(CG_HIT)?.onChainId).toBe(ON_HIT); + // The other two pre-subscribed CGs were NOT bound and NOT reconciled. + expect(internals.subscribedContextGraphs.get(CG_MISS_A)?.onChainId).toBeUndefined(); + expect(internals.subscribedContextGraphs.get(CG_MISS_B)?.onChainId).toBeUndefined(); + expect(triggered).toEqual([CG_HIT]); + }); + + it('live KACG nudge handler: an already-bound CG reconciles directly without a self-prime scan', async () => { + // The other live branch: the event id already resolves to a local CG. It must + // reconcile that CG straight away (subscribed or core-hosted), independent of + // the pre-subscribed self-prime loop. + const chain = new MockChainAdapter(); + agent = await DKGAgent.create({ name: 'KacgNudgeBound', chainAdapter: chain }); + stubNode(agent); + const internals = agent as unknown as AgentInternals; + + const CG_BOUND = 'gh1098-nudge-bound'; + const ON_BOUND = '8080'; + internals.subscribedContextGraphs.set(CG_BOUND, { subscribed: true, onChainId: ON_BOUND }); + + const triggered: string[] = []; + internals.reconcileCoalescer = { trigger: (cg: string) => { triggered.push(cg); } }; + + const reconciled = await internals.handleKARegisteredNudge(ON_BOUND, 1n, createOperationContext('system')); + expect(reconciled).toBe(CG_BOUND); + expect(triggered).toEqual([CG_BOUND]); + }); +}); diff --git a/packages/cli/src/publisher-runner.ts b/packages/cli/src/publisher-runner.ts index 030d1b355..ac02c97ff 100644 --- a/packages/cli/src/publisher-runner.ts +++ b/packages/cli/src/publisher-runner.ts @@ -274,10 +274,17 @@ async function createPublisherRuntimeFromBase(args: PublisherRuntimeBaseArgs): P throw new Error(`No publisher configured for wallet ${walletId}`); } const encryption = await args.publishEncryptionFactory?.(publishOptions); + // GH #1121 — the agent-resolved, chainKey-bound AEAD closure MUST win over + // any callback already on publishOptions. The async-lift mapper now + // pre-populates a fail-closed default `encryptInlinePayload` for non-public + // CGs (so plaintext can never silently ship); that default must only apply + // when the real factory yields nothing — otherwise it would shadow the + // real curated-publish encryption and make every private async publish + // throw. Hence: real factory first, mapper default as the fallback. const publishOptionsWithEncryption: PublishOptions = { ...publishOptions, - encryptInlinePayload: publishOptions.encryptInlinePayload ?? encryption?.encryptInlinePayload, - encryptInlineChunked: publishOptions.encryptInlineChunked ?? encryption?.encryptInlineChunked, + encryptInlinePayload: encryption?.encryptInlinePayload ?? publishOptions.encryptInlinePayload, + encryptInlineChunked: encryption?.encryptInlineChunked ?? publishOptions.encryptInlineChunked, }; const v10ACKProvider = publishOptionsWithEncryption.v10ACKProvider ?? args.v10ACKProviderFactory?.() diff --git a/packages/cli/test/publisher-runner-private-encryption.test.ts b/packages/cli/test/publisher-runner-private-encryption.test.ts new file mode 100644 index 000000000..7b22644f0 --- /dev/null +++ b/packages/cli/test/publisher-runner-private-encryption.test.ts @@ -0,0 +1,124 @@ +/** + * GH #1121 / KoOvD — CLI runtime-boundary regression for the inline-encryption + * callback precedence. + * + * The original failure mode was at THIS boundary (`publisher-runner.ts`'s + * `publishExecutor`): the async-lift mapper pre-populates a fail-closed default + * `encryptInlinePayload` for non-public CGs (so plaintext can never silently + * ship), and the runner must let the REAL `publishEncryptionFactory` callback + * win over that default — otherwise every private async publish would invoke the + * throwing fail-closed default instead of encrypting. + * + * The publisher-level unit tests only assert the mapper's `resolved`-precedence; + * they do NOT cover the runner's `?? publishOptions.encryptInlinePayload` merge. + * This drives a real non-public (ownerOnly) async-lift publish end to end through + * `createPublisherRuntimeFromAgent` and asserts that the callback `publisher.publish` + * actually receives is the factory's closure — NOT the fail-closed mapper default. + * Hermetic — NoChainAdapter, in-memory store, no network. + */ +import { describe, expect, it, vi } from 'vitest'; +import { mkdtemp } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { ethers } from 'ethers'; +import { NoChainAdapter } from '@origintrail-official/dkg-chain'; +import { generateEd25519Keypair, TypedEventBus } from '@origintrail-official/dkg-core'; +import { + DKGPublisher, + isFailClosedInlineEncrypt, + type PublishOptions, +} from '@origintrail-official/dkg-publisher'; +import { createTripleStore } from '@origintrail-official/dkg-storage'; +import { addPublisherWallet } from '../src/publisher-wallets.js'; +import { createPublisherRuntimeFromAgent } from '../src/publisher-runner.js'; + +describe('publisher runner — private (non-public) inline-encryption callback handoff', () => { + it('passes the real publishEncryptionFactory callback to publisher.publish for an ownerOnly lift (not the fail-closed mapper default)', async () => { + const dataDir = await mkdtemp(join(tmpdir(), 'dkg-publisher-priv-enc-')); + const wallet = ethers.Wallet.createRandom(); + const store = await createTripleStore({ backend: 'oxigraph' }); + const keypair = await generateEd25519Keypair(); + await addPublisherWallet(dataDir, wallet.privateKey); + + const writer = new DKGPublisher({ + store, + chain: new NoChainAdapter(), + eventBus: new TypedEventBus(), + keypair, + publisherPrivateKey: wallet.privateKey, + }); + const write = await writer.writeToWorkspace('music-social', [ + { + subject: 'urn:local:/rihana', + predicate: 'http://schema.org/name', + object: '"Rihana"', + graph: '', + }, + ], { publisherPeerId: 'peer-1' }); + + // The agent-resolved, chainKey-bound AEAD closures. Stable references so the + // assertion can prove THESE reached publish (vs. the throwing fail-closed default). + const realInline = async (b: Uint8Array): Promise => new Uint8Array([...b, 0xee]); + const realChunked = async (): Promise<{ ciphertextChunksRoot: Uint8Array; ciphertextChunkCount: number; totalCiphertextBytes: number }> => ({ + ciphertextChunksRoot: ethers.getBytes(ethers.id('priv-enc-chunk-root')), + ciphertextChunkCount: 1, + totalCiphertextBytes: 1, + }); + + // Capture what the runner's publishExecutor hands to publisher.publish, then + // run the real publish (local finalization under NoChainAdapter). + let captured: PublishOptions | undefined; + const originalPublish = DKGPublisher.prototype.publish; + const publishSpy = vi + .spyOn(DKGPublisher.prototype, 'publish') + .mockImplementation(async function (this: DKGPublisher, opts: PublishOptions) { + captured ??= opts; + return originalPublish.call(this, opts); + }); + + try { + const runtime = await createPublisherRuntimeFromAgent({ + dataDir, + store, + keypair, + chainBase: undefined, + pollIntervalMs: 10, + errorBackoffMs: 10, + publishEncryptionFactory: () => ({ + encryptInlinePayload: realInline, + encryptInlineChunked: realChunked, + }), + }); + + const jobId = await runtime.publisher.lift({ + swmId: 'swm-main', + shareOperationId: write.shareOperationId, + roots: ['urn:local:/rihana'], + contextGraphId: 'music-social', + namespace: 'aloha', + scope: 'person-profile', + transitionType: 'CREATE', + authority: { type: 'owner', proofRef: 'proof:owner:1' }, + // The crux: a NON-public publish — the mapper pre-populates the fail-closed + // default, and the runner must override it with the real factory callback. + accessPolicy: 'ownerOnly', + }); + + const processed = await runtime.publisher.processNext(wallet.address); + expect(processed?.jobId).toBe(jobId); + + // The runner forwarded the lift's non-public access policy through. + expect(captured?.accessPolicy).toBe('ownerOnly'); + // publisher.publish received the REAL factory callbacks, by reference … + expect(captured?.encryptInlinePayload).toBe(realInline); + expect(captured?.encryptInlineChunked).toBe(realChunked); + // … and explicitly NOT the throwing fail-closed mapper default. + expect(isFailClosedInlineEncrypt(captured?.encryptInlinePayload)).toBe(false); + + await runtime.stop(); + } finally { + publishSpy.mockRestore(); + await store.close(); + } + }); +}); diff --git a/packages/publisher/src/async-lift-publish-options.ts b/packages/publisher/src/async-lift-publish-options.ts index c197f08f5..4651c53a4 100644 --- a/packages/publisher/src/async-lift-publish-options.ts +++ b/packages/publisher/src/async-lift-publish-options.ts @@ -37,6 +37,48 @@ export interface LiftResolvedPublishSlice { readonly onPhase?: PhaseCallback; readonly receiverSignatureProvider?: ReceiverSignatureProvider; readonly publishContextGraphId?: string; + /** + * GH #1121 — inline-encryption callbacks for private/curated CGs, threaded + * from the async worker (the agent-resolved, chainKey-bound AEAD closures) + * into the mapper exactly like `onPhase`/`receiverSignatureProvider`. When a + * non-public CG reaches the mapper WITHOUT these, the mapper substitutes a + * fail-closed default so cores can never silently receive plaintext. + */ + readonly encryptInlinePayload?: PublishOptions['encryptInlinePayload']; + readonly encryptInlineChunked?: PublishOptions['encryptInlineChunked']; +} + +/** + * GH #1121 fail-closed default. A private/curated async publish must never ship + * plaintext to cores. The real chainKey-bound AEAD closure is resolved live at + * execute time by the agent and threaded through `LiftResolvedPublishSlice` + * (and, in the CLI runtime, merged in `publisher-runner.ts` where it takes + * precedence over this default). If a non-public CG ever reaches the publisher + * with NO inline-encryption callback, this default makes the publish FAIL LOUD + * instead of leaking plaintext. + */ +const FAIL_CLOSED_MARKER = '__dkgFailClosedInlineEncrypt'; +const failClosedInlineEncrypt: NonNullable = Object.assign( + (() => { + throw new Error( + 'Async-lift private/curated CG publish requires an encryptInlinePayload factory threaded ' + + 'through LiftResolvedPublishSlice (a non-public context graph must not ship plaintext to cores). ' + + 'Wire publishEncryptionFactory in the async publisher runtime.', + ); + }) as NonNullable, + { [FAIL_CLOSED_MARKER]: true as const }, +); + +/** + * True iff `cb` is the mapper's fail-closed default (not a real agent-resolved + * encryption callback). The publisher uses this to skip the encrypted-inline + * path on a chainless/local publish that ships nothing to cores — there is no + * plaintext leak to guard, so the default must not throw — while still honouring + * any REAL callback the caller wired, and still firing the default (fail-closed) + * for an actual on-chain publish with no real encryption resolved. + */ +export function isFailClosedInlineEncrypt(cb: unknown): boolean { + return typeof cb === 'function' && (cb as unknown as Record)[FAIL_CLOSED_MARKER] === true; } export interface LiftPublishMappingInput { @@ -108,6 +150,22 @@ export function mapLiftRequestToPublishOptions(input: LiftPublishMappingInput): throw new Error('Lift publish mapping only allows allowedPeers when accessPolicy is allowList'); } + // GH #1121 — derive the inline-encryption path. Public CGs MUST stay + // plaintext: force BOTH inline callbacks to `undefined` so even a resolver + // that supplies a default encryption factory cannot push DKGPublisher onto + // the encrypted-inline path for a public CG (otherwise public content would + // be silently encrypted at rest and unreadable by readers). Non-public CGs + // MUST carry an inline-encryption callback: use the agent-resolved one + // threaded through `resolved`, else the fail-closed default so a missing + // factory throws at publish time rather than leaking plaintext. The chunked + // callback is optional (the inline blob path is the floor). + const encryptInlinePayload = accessPolicy === 'public' + ? undefined + : (input.resolved.encryptInlinePayload ?? failClosedInlineEncrypt); + const encryptInlineChunked = accessPolicy === 'public' + ? undefined + : input.resolved.encryptInlineChunked; + // Request flags (enqueue-time caller intent) win over resolved hints (per-process defaults). const entityProofs = input.request.entityProofs ?? input.resolved.entityProofs; const publishEpochs = input.request.publishEpochs; @@ -133,6 +191,8 @@ export function mapLiftRequestToPublishOptions(input: LiftPublishMappingInput): onPhase: input.resolved.onPhase, receiverSignatureProvider: input.resolved.receiverSignatureProvider, publishContextGraphId: input.resolved.publishContextGraphId, + encryptInlinePayload, + encryptInlineChunked, ...(publishEpochs !== undefined ? { publishEpochs } : {}), diff --git a/packages/publisher/src/async-lift-publisher-impl.ts b/packages/publisher/src/async-lift-publisher-impl.ts index d286e0275..f1706d856 100644 --- a/packages/publisher/src/async-lift-publisher-impl.ts +++ b/packages/publisher/src/async-lift-publisher-impl.ts @@ -28,6 +28,7 @@ import { } from './async-lift-publish-result.js'; import { prepareAsyncPublishPayload, type AsyncPreparedPublishPayload, type LiftResolvedPublishSlice } from './async-lift-publish-options.js'; import { canonicalRootIri, validateLiftPublishPayload } from './async-lift-validation.js'; +import { computePrivateRootV10 } from './merkle.js'; import { subtractFinalizedExactQuads } from './async-lift-subtraction.js'; import { resolveLiftWorkspaceSlice } from './workspace-resolution.js'; import { @@ -1001,11 +1002,18 @@ export class TripleStoreAsyncLiftPublisher implements AsyncLiftPublisher { const canonicalRoot = job.validation.canonicalRootMap[sourceRoot] ?? sourceRoot; const canonicalQuads = canonicalizePrivateStagedQuads(staged, job.validation.canonicalRootMap); + // GH #1078 — derive the commitment from the finalized slice's + // privateMerkleRoot so a re-finalize that produces DIFFERENT content + // supersedes the stale slice (re-finalizing identical content is + // idempotent: same digest → append+dedup, no churn). + const commitmentRoot = computePrivateRootV10(canonicalQuads); + const commitmentId = commitmentRoot ? Buffer.from(commitmentRoot).toString('hex') : undefined; await privateStore.storePrivateTriples( job.request.contextGraphId, canonicalRoot, canonicalQuads, job.request.subGraphName, + commitmentId, ); await privateStore.deletePrivateTriplesForOperation( job.request.contextGraphId, diff --git a/packages/publisher/src/dkg-publisher.ts b/packages/publisher/src/dkg-publisher.ts index 49b76a52b..f73756945 100644 --- a/packages/publisher/src/dkg-publisher.ts +++ b/packages/publisher/src/dkg-publisher.ts @@ -17,8 +17,11 @@ import { computeFlatKCMerkleLeafCountV10, } from './merkle.js'; import { validatePublishRequest } from './validation.js'; +import { isFailClosedInlineEncrypt } from './async-lift-publish-options.js'; import { generateConfirmedFullMetadata, + buildDeterministicTokenRows, + compareRootIris, generateOwnershipQuads, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, @@ -1898,8 +1901,19 @@ export class DKGPublisher implements Publisher { // many entities it contains. The on-chain KA count and ACK digest stay at // one below, while these token IDs remain compatibility labels for // per-root response/meta subjects (`/1`, `/2`, ...). + // GH #936 — mint the compatibility tokenIds over a CANONICAL (lexicographic + // by rootEntity) order, the SAME order the replica reconcile/gossip path + // uses in `FinalizationHandler.promoteSharedMemoryToCanonical`. Without this, + // the ORIGINATOR would label `/` by input-quad order while + // replicas label by sorted order, so a multi-root KC could resolve a + // different root for the same token label depending on which node a client + // queries. These tokenIds are non-on-chain compatibility labels (the + // on-chain KA count is 1), so a content-derived sort is safe. + const orderedEntries = [...canonical.manifestEntries].sort((a, b) => + compareRootIris(a.rootEntity, b.rootEntity), + ); let compatibilityTokenId = 1n; - for (const entry of canonical.manifestEntries) { + for (const entry of orderedEntries) { const tokenId = compatibilityTokenId++; manifestEntries.push({ tokenId, @@ -1978,14 +1992,31 @@ export class DKGPublisher implements Publisher { // to ACK / chain digests — moving it past the chain-success branch // would risk a race where the publisher returns 'confirmed' before // its own private store has the data. - for (const entry of canonical.manifestEntries) { - const entityPrivateQuads = privateQuads.filter( - (q) => q.subject === entry.rootEntity || q.subject.startsWith(entry.rootEntity + '/.well-known/genid/'), - ); - if (entityPrivateQuads.length > 0) { - await this.privateStore.storePrivateTriples(contextGraphId, entry.rootEntity, entityPrivateQuads, options.subGraphName); + // GH #1078 — persist the finalized private slices. DEFERRED to the terminal + // branches (post-chain-confirmation, or the intentional-local finalize) and + // NEVER run on the chain-failure path. Because `storePrivateTriples(…, + // commitmentId)` now SUPERSEDES a root's prior private slice when the + // commitment differs, running it pre-chain would let a failed/rejected + // re-publish delete the private data of the still-current KA while the chain + // still points at the old version. Gating it on confirmation keeps the + // private store consistent with the committed `privateMerkleRoot`, and + // invoking it BEFORE the publish returns 'confirmed' preserves the + // no-"confirmed-before-data" guarantee the pre-chain insert used to give. + const persistFinalizedPrivateSlices = async (): Promise => { + for (const entry of canonical.manifestEntries) { + const entityPrivateQuads = privateQuads.filter( + (q) => q.subject === entry.rootEntity || q.subject.startsWith(entry.rootEntity + '/.well-known/genid/'), + ); + if (entityPrivateQuads.length > 0) { + // Tag the stored slice with the commitment this root committed (its + // privateMerkleRoot) so a later re-publish supersedes the stale slice. + const commitmentId = entry.privateMerkleRoot + ? Buffer.from(entry.privateMerkleRoot).toString('hex') + : undefined; + await this.privateStore.storePrivateTriples(contextGraphId, entry.rootEntity, entityPrivateQuads, options.subGraphName, commitmentId); + } } - } + }; onPhase?.('store', 'end'); @@ -2102,7 +2133,19 @@ export class DKGPublisher implements Publisher { // with NO_DATA_IN_SWM — the exact bug §1.1 surfaces. Public CGs keep // the existing behaviour: `fromSharedMemory` → cores look up SWM // locally; otherwise plaintext inline. - const useEncryptedInline = typeof options.encryptInlinePayload === 'function'; + // GH #1121 — take the encrypted-inline path whenever a REAL encryption + // callback is wired. The ONE exception: skip the async-lift mapper's + // fail-closed DEFAULT on a chainless / local-only publish (ownerOnly KA on + // an unregistered CG, chain-not-ready node, …). Such a publish ships nothing + // to other nodes, so there is no plaintext-to-cores leak to guard — and the + // default (which exists only to prevent that leak) cannot resolve a real + // chain-key off-chain, so invoking it would needlessly fail a legitimate + // local publish. For an actual on-chain publish the default still fires + // (fail-closed): a private payload is never shipped to cores in the clear. + const inlineEncryptCb = options.encryptInlinePayload; + const useEncryptedInline = + typeof inlineEncryptCb === 'function' + && (canAttemptOnChainPublish || !isFailClosedInlineEncrypt(inlineEncryptCb)); // OT-RFC-49 / WS-D — a curated publish is now identified by a non-zero // catalog commitment. The on-chain commitment + the core ACK verify the // PUBLIC `_catalog`; the PRIVATE data stays encrypted for MEMBERS only. @@ -2542,6 +2585,9 @@ export class DKGPublisher implements Publisher { } this.log.info(ctx, `Storing ${normalizedQuads.length} triples in local store (${reasonLog})`); await this.store.insert(normalizedQuads); + // GH #1078 — persist private slices on this intentional-local terminal + // branch too (a chainless / ownerOnly publish still finalizes here). + await persistFinalizedPrivateSlices(); await this.store.insert(tentativeMeta); // B3: only now that the local publish has persisted do we refresh the // public catalog entry (CLEAR/REPLACE — see persistCatalogEntry). @@ -2929,6 +2975,15 @@ export class DKGPublisher implements Publisher { chainId: this.chain.chainId, }, ); + // GH #936 — append the SHARED deterministic per-root token rows (the + // same helper the gossip / chain-reconcile path uses) so the originator + // exposes the IDENTICAL `/` → root map as replicas. kaMetadata + // is already in canonical (sorted) tokenId order. graph = default + // `/_meta` so the remap below routes them to the per-cgId `_meta`. + confirmedQuads = [ + ...confirmedQuads, + ...buildDeterministicTokenRows(ual, kaMetadata, `did:dkg:context-graph:${contextGraphId}/_meta`), + ]; if (options.targetMetaGraphUri) { const defaultMeta = `did:dkg:context-graph:${contextGraphId}/_meta`; confirmedQuads = confirmedQuads.map((q) => @@ -2954,6 +3009,10 @@ export class DKGPublisher implements Publisher { this.log.info(ctx, `Storing ${vmQuads.length} triples in ${vmGraph} (post-confirmation)`); await this.store.insert(vmQuads); await this.store.insert(confirmedQuads); + // GH #1078 — supersede/persist private slices only now that the chain + // has confirmed (before returning 'confirmed', so no read sees the KA + // confirmed without its private data). + await persistFinalizedPrivateSlices(); await stampTrustLevel( this.store, vmGraph, diff --git a/packages/publisher/src/index.ts b/packages/publisher/src/index.ts index 11e17f684..1a2f20178 100644 --- a/packages/publisher/src/index.ts +++ b/packages/publisher/src/index.ts @@ -25,7 +25,7 @@ export { computeKCRootV10, } from './merkle.js'; export { validatePublishRequest, type ValidationResult, type ValidationOptions } from './validation.js'; -export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; +export { generateKCMetadata, generateTentativeMetadata, generateConfirmedFullMetadata, buildDeterministicTokenRows, compareRootIris, getTentativeStatusQuad, getConfirmedStatusQuad, generateOwnershipQuads, generateShareMetadata, generateWorkspaceMetadata, generateSubGraphRegistration, subGraphDeregistrationSparql, subGraphDiscoverySparql, subGraphWritersSparql, toHex, resolveUalByBatchId, updateMetaMerkleRoot, promoteUpdatedKaToPerCgId, restateKaPartition, restateLabelGraphForUpdate, readMaterializedVersion, shouldApplyMaterialization, writeMaterializedVersion, withMaterializationLock, compareMaterializedVersion, type MaterializedVersion, generateAssertionCreatedMetadata, generateAssertionPromotedMetadata, generateAssertionUpdatedMetadata, generateAssertionDiscardedMetadata, assertionStateQuad, assertionLayerQuad, deriveStatus, assertionLayerPointerQuad, stampLayerPointerSparql, type LifecycleMetadataOptions, WM_CURRENT_ASSERTION_PRED, SWM_CURRENT_ASSERTION_PRED, VM_CURRENT_ASSERTION_PRED, KA_ID_PRED, RESERVED_UAL_PRED, PROV_WAS_REVISION_OF, type KaStatus, type StatusPointers, type KCMetadata, type KAMetadata, type OnChainProvenance, type ShareMetadata, type WorkspaceMetadata, type SubGraphRegistration, type AssertionCreatedMeta, type AssertionPromotedMeta, type AssertionUpdatedMeta, type AssertionDiscardedMeta } from './metadata.js'; export { DKGPublisher, StaleWriteError, @@ -191,6 +191,9 @@ export { } from './async-lift-runner.js'; export { mapLiftRequestToPublishOptions, + prepareAsyncPublishPayload, + isFailClosedInlineEncrypt, + type AsyncPreparedPublishPayload, type LiftResolvedPublishSlice, type LiftPublishMappingInput, } from './async-lift-publish-options.js'; diff --git a/packages/publisher/src/metadata.ts b/packages/publisher/src/metadata.ts index c54ee42fe..fabca7406 100644 --- a/packages/publisher/src/metadata.ts +++ b/packages/publisher/src/metadata.ts @@ -298,6 +298,47 @@ export function generateConfirmedFullMetadata( ]; } +/** + * GH #936 — explicit, deterministic per-root token map + * (`/` dkg:tokenId / dkg:entity) for MULTI-root KCs. Emitted via a + * SHARED helper so the publisher (originator) and the gossip / chain-reconcile + * replica paths expose an IDENTICAL, queryable rootEntity→tokenId mapping — the + * same multi-root KC must not surface different token rows depending on which + * node materialised it. Kept OUT of generateKCMetadata (metadata.test.ts pins + * that output to the collapsed `dkg:rootEntity` shape and forbids these + * predicates); both call sites append these rows alongside the confirmed + * metadata. `kaEntries` MUST already be in the canonical + * (lexicographically-sorted-by-rootEntity) tokenId order — both call sites sort + * before minting. + */ +/** + * GH #936 — the canonical root ordering used for deterministic compatibility + * tokenId assignment. The publisher (originator), the gossip path, and the + * chain-reconcile path MUST all assign `/` over roots sorted by + * THIS comparator, so every node derives the identical rootEntity→tokenId map. + * Shared here so the invariant is API-enforced rather than comment-enforced. + */ +export function compareRootIris(a: string, b: string): number { + return a < b ? -1 : a > b ? 1 : 0; +} + +export function buildDeterministicTokenRows( + ual: string, + kaEntries: ReadonlyArray<{ tokenId: bigint; rootEntity: string }>, + metaGraph: string, +): Quad[] { + if (kaEntries.length <= 1) return []; + const rows: Quad[] = []; + for (const ka of kaEntries) { + const subject = `${ual}/${ka.tokenId}`; + rows.push( + mq(subject, `${DKG}tokenId`, intLit(ka.tokenId), metaGraph), + mq(subject, DKG_ENTITY, ka.rootEntity, metaGraph), + ); + } + return rows; +} + function mq(s: string, p: string, o: string, g: string): Quad { return { subject: s, predicate: p, object: o, graph: g }; } diff --git a/packages/publisher/test/async-lift-canonicalization-and-encryption.test.ts b/packages/publisher/test/async-lift-canonicalization-and-encryption.test.ts new file mode 100644 index 000000000..63d9db74c --- /dev/null +++ b/packages/publisher/test/async-lift-canonicalization-and-encryption.test.ts @@ -0,0 +1,142 @@ +/** + * Liveness/regression tests for two async-lift publish gaps. + * + * GH #1122 — "Sync and async publish canonicalize root subjects differently". + * The async lift validator rewrites caller-provided root IRIs to + * generated `dkg:::/-` roots, so the same + * domain payload yields different RDF subjects than sync publish — + * breaking stable IRI linking for integrations. + * https://github.com/OriginTrail/dkg/issues/1122 + * + * GH #1121 — "publishAsync should support encrypted VM publishing for + * curated/private context graphs". The sync path resolves + * `encryptInlinePayload` / `encryptInlineChunked`; the async-lift + * PublishOptions mapping carries no encryption path, so a private-CG + * async publish would ship plaintext to cores. + * https://github.com/OriginTrail/dkg/issues/1121 + * + * Both are `it.fails` repros: the assertion of the correct (sync-parity) + * behaviour fails today. When fixed, drop `.fails` and close the issue. + * Hermetic — pure functions, no chain/network. + */ +import { describe, expect, it } from 'vitest'; +import { validateLiftPublishPayload } from '../src/async-lift-validation.js'; +import { mapLiftRequestToPublishOptions } from '../src/async-lift-publish-options.js'; +import type { LiftRequest } from '../src/lift-job-types.js'; + +const CALLER_ROOT = 'urn:dmaast:tenant:tenant-a'; + +const baseRequest: LiftRequest = { + swmId: 'swm-main', + shareOperationId: 'op-1122', + roots: [CALLER_ROOT], + contextGraphId: 'dmaast', + namespace: 'tenants', + scope: 'devnet', + transitionType: 'CREATE', + authority: { type: 'owner', proofRef: 'devnet-proof' }, +}; + +describe('GH #1122 — async lift preserves caller-provided root IRIs (sync parity)', () => { + it('does not rewrite a caller root IRI to a generated dkg:… subject', () => { + const out = validateLiftPublishPayload({ + request: baseRequest, + resolved: { + quads: [ + { subject: CALLER_ROOT, predicate: 'https://schema.org/name', object: '"Tenant A"', graph: 'g' }, + ], + }, + }); + // Sync publish keeps the caller IRI as the subject; async must match. + expect(out.resolved.quads[0].subject).toBe(CALLER_ROOT); + }); +}); + +describe('GH #1121 — async lift carries an inline-encryption path for private CGs', () => { + it('a private (ownerOnly) async publish maps to PublishOptions with an encryption callback', () => { + const opts = mapLiftRequestToPublishOptions({ + request: { ...baseRequest, accessPolicy: 'ownerOnly' }, + validation: { authorityProofRef: 'devnet-proof', transitionType: 'CREATE' }, + resolved: { + quads: [ + { subject: CALLER_ROOT, predicate: 'https://schema.org/name', object: '"Tenant A"', graph: 'g' }, + ], + accessPolicy: 'ownerOnly', + publisherPeerId: '12D3KooWGH1121PrivatePublisherPeerIdForTest', + }, + }); + expect(opts.accessPolicy).toBe('ownerOnly'); + // A private publish must not ship plaintext to cores — there has to be an + // inline-encryption path threaded through, like the sync publish() does. + expect(opts.encryptInlinePayload).toBeDefined(); + }); + + it('PRESERVES the agent-resolved real encryption callbacks (does not shadow them with the fail-closed default)', () => { + // The fail-closed default exists only so a non-public CG can never ship + // plaintext when NO factory is wired. When the async worker DOES thread the + // agent-resolved chainKey-bound AEAD closures through `resolved`, the mapper + // must pass THOSE through verbatim — otherwise a private publish would invoke + // the throwing default and fail instead of encrypting. (Guards the + // publishEncryptionFactory wiring / precedence from silently regressing.) + const realInline = async (b: Uint8Array): Promise => new Uint8Array([...b, 0xff]); + const realChunked = async (): Promise => { /* member fan-out */ }; + const opts = mapLiftRequestToPublishOptions({ + request: { ...baseRequest, accessPolicy: 'ownerOnly' }, + validation: { authorityProofRef: 'devnet-proof', transitionType: 'CREATE' }, + resolved: { + quads: [ + { subject: CALLER_ROOT, predicate: 'https://schema.org/name', object: '"Tenant A"', graph: 'g' }, + ], + accessPolicy: 'ownerOnly', + publisherPeerId: '12D3KooWGH1121PrivatePublisherPeerIdForTest', + encryptInlinePayload: realInline, + encryptInlineChunked: realChunked, + }, + }); + expect(opts.encryptInlinePayload).toBe(realInline); + expect(opts.encryptInlineChunked).toBe(realChunked); + }); + + it('a PUBLIC async publish stays plaintext (no encryption callback forced)', () => { + const opts = mapLiftRequestToPublishOptions({ + request: { ...baseRequest, accessPolicy: 'public' }, + validation: { authorityProofRef: 'devnet-proof', transitionType: 'CREATE' }, + resolved: { + quads: [ + { subject: CALLER_ROOT, predicate: 'https://schema.org/name', object: '"Tenant A"', graph: 'g' }, + ], + accessPolicy: 'public', + }, + }); + expect(opts.accessPolicy).toBe('public'); + // A public CG must NOT get a forced encryption hook — that would push the + // publisher onto the encrypted-inline path and break public ACK verification. + expect(opts.encryptInlinePayload).toBeUndefined(); + }); + + it('a PUBLIC async publish FORCES both inline callbacks to undefined even when the resolver supplies them', () => { + // Defence-in-depth for the public path: a resolver (or a per-process default + // encryption factory) might hand a non-public-looking callback through + // `resolved` even for a public CG. The mapper must NOT forward it — otherwise + // DKGPublisher's `useEncryptedInline` gate (`typeof inlineEncryptCb === 'function'`) + // would silently encrypt public content at rest, making it unreadable to + // public readers and breaking public ACK verification. Force BOTH to undefined. + const strayInline = async (b: Uint8Array): Promise => new Uint8Array([...b, 0xff]); + const strayChunked = async (): Promise => { /* would fan out members */ }; + const opts = mapLiftRequestToPublishOptions({ + request: { ...baseRequest, accessPolicy: 'public' }, + validation: { authorityProofRef: 'devnet-proof', transitionType: 'CREATE' }, + resolved: { + quads: [ + { subject: CALLER_ROOT, predicate: 'https://schema.org/name', object: '"Tenant A"', graph: 'g' }, + ], + accessPolicy: 'public', + encryptInlinePayload: strayInline, + encryptInlineChunked: strayChunked, + }, + }); + expect(opts.accessPolicy).toBe('public'); + expect(opts.encryptInlinePayload).toBeUndefined(); + expect(opts.encryptInlineChunked).toBeUndefined(); + }); +}); diff --git a/packages/publisher/test/async-lift-publish-options.test.ts b/packages/publisher/test/async-lift-publish-options.test.ts index 347690a5c..a5297f1c3 100644 --- a/packages/publisher/test/async-lift-publish-options.test.ts +++ b/packages/publisher/test/async-lift-publish-options.test.ts @@ -1,5 +1,5 @@ import { describe, expect, it } from 'vitest'; -import { mapLiftRequestToPublishOptions, prepareAsyncPublishPayload, type LiftPublishMappingInput } from '../src/async-lift-publish-options.js'; +import { mapLiftRequestToPublishOptions, prepareAsyncPublishPayload, isFailClosedInlineEncrypt, type LiftPublishMappingInput } from '../src/async-lift-publish-options.js'; describe('mapLiftRequestToPublishOptions', () => { function baseInput(): LiftPublishMappingInput { @@ -578,3 +578,101 @@ describe('mapLiftRequestToPublishOptions', () => { expect(options.precomputedAttestation).toBeUndefined(); }); }); + +// GH #1121 follow-up — the inline-encryption callback precedence/forcing that +// `async-lift-publisher-impl` depends on, asserted at the `prepareAsyncPublishPayload` +// seam the impl actually calls (not just the lower-level mapper). The impl threads +// `prepared.publishOptions` straight into `publishExecutor`/`publisher.publish`, so +// whatever lands here is exactly what the publisher's `useEncryptedInline` gate sees. +describe('prepareAsyncPublishPayload — inline-encryption callback handoff to publisher.publish', () => { + function baseInput(): LiftPublishMappingInput { + return { + request: { + swmId: 'swm-1', + shareOperationId: 'op-1', + roots: ['urn:local:/rihana'], + contextGraphId: 'music-social', + namespace: 'aloha', + scope: 'person-profile', + transitionType: 'CREATE', + authority: { type: 'owner', proofRef: 'proof:owner:1' }, + }, + validation: { + authorityProofRef: 'proof:owner:1', + priorVersion: undefined, + transitionType: 'CREATE', + }, + resolved: { + quads: [ + { + subject: 'did:dkg:music-social:rihana', + predicate: 'http://schema.org/name', + object: '"Rihana"', + graph: 'did:dkg:context-graph:music-social/_data', + }, + ], + }, + }; + } + + it('forwards the agent-resolved real encryption factory into publishOptions (NOT the fail-closed default) for a non-public publish', () => { + // KoOvD: the impl hands `prepared.publishOptions` to `publishExecutor`, which + // calls `publisher.publish`. If the mapper ever shadowed the real factory with + // the throwing fail-closed default, every private async publish would throw at + // encryption time. Pin that the REAL closure reaches the publish boundary. + const realInline = async (b: Uint8Array): Promise => new Uint8Array([...b, 0xff]); + const realChunked = async (): Promise => { /* member key fan-out */ }; + const prepared = prepareAsyncPublishPayload({ + ...baseInput(), + request: { ...baseInput().request, accessPolicy: 'ownerOnly' }, + resolved: { + ...baseInput().resolved, + accessPolicy: 'ownerOnly', + publisherPeerId: '12D3KooWPublisher', + encryptInlinePayload: realInline, + encryptInlineChunked: realChunked, + }, + }); + expect(prepared.publishOptions.accessPolicy).toBe('ownerOnly'); + expect(prepared.publishOptions.encryptInlinePayload).toBe(realInline); + expect(prepared.publishOptions.encryptInlineChunked).toBe(realChunked); + expect(isFailClosedInlineEncrypt(prepared.publishOptions.encryptInlinePayload)).toBe(false); + }); + + it('hands the fail-closed default to publisher.publish for a non-public publish with NO resolved factory (so it throws rather than leaking plaintext)', () => { + // When no factory is wired, the publish boundary must receive the throwing + // fail-closed marker — never `undefined`, which would silently ship plaintext. + const prepared = prepareAsyncPublishPayload({ + ...baseInput(), + request: { ...baseInput().request, accessPolicy: 'ownerOnly' }, + resolved: { + ...baseInput().resolved, + accessPolicy: 'ownerOnly', + publisherPeerId: '12D3KooWPublisher', + }, + }); + expect(prepared.publishOptions.encryptInlinePayload).toBeDefined(); + expect(isFailClosedInlineEncrypt(prepared.publishOptions.encryptInlinePayload)).toBe(true); + }); + + it('hands publisher.publish NO inline-encryption callbacks for a public publish, even when the resolver supplies them', () => { + // KoOvA at the handoff boundary: a stray resolver/default callback must not + // reach the publisher for a public CG, or its `useEncryptedInline` gate would + // encrypt public content at rest. Both inline callbacks must be undefined. + const strayInline = async (b: Uint8Array): Promise => new Uint8Array([...b, 0xff]); + const strayChunked = async (): Promise => { /* would fan out members */ }; + const prepared = prepareAsyncPublishPayload({ + ...baseInput(), + request: { ...baseInput().request, accessPolicy: 'public' }, + resolved: { + ...baseInput().resolved, + accessPolicy: 'public', + encryptInlinePayload: strayInline, + encryptInlineChunked: strayChunked, + }, + }); + expect(prepared.publishOptions.accessPolicy).toBe('public'); + expect(prepared.publishOptions.encryptInlinePayload).toBeUndefined(); + expect(prepared.publishOptions.encryptInlineChunked).toBeUndefined(); + }); +}); diff --git a/packages/query/src/dkg-query-engine.ts b/packages/query/src/dkg-query-engine.ts index 936c8246f..ca08b2462 100644 --- a/packages/query/src/dkg-query-engine.ts +++ b/packages/query/src/dkg-query-engine.ts @@ -499,6 +499,21 @@ export class DKGQueryEngine implements QueryEngine { } } + // GH #1098 — include the per-cgId VM data graph(s) `/context/`. + // Chain-driven VM reconcile (and any per-cgId-only materialisation — e.g. a + // peer that subscribed BEFORE publish and recovered via the reconcile sweep, + // which writes confirmed data into the per-cgId graph WITHOUT the root-label + // dual-write copy) lands confirmed data ONLY in `/context/`. The base + // `verifiable-memory` resolution above only reads the root content graph + + // `_verifiable_memory/*`, so that data was invisible to a VM read (the + // observable symptom of #1098: a pre-subscribed peer never "saw" the + // published KA even though it had materialised it). Union the per-cgId DATA + // graphs (resolved from the store, so no subscription state is needed) into + // the allow-set for an unscoped VM read. + if (view === 'verifiable-memory' && !options.verifiedGraph && !options.subGraphName) { + allGraphs.push(...(await this.discoverContextGraphPerCgIdDataGraphs(contextGraphId))); + } + // De-dup so a sub-graph never gets unioned twice. const dedupedGraphs = [...new Set(allGraphs)]; allGraphs.length = 0; @@ -642,6 +657,25 @@ export class DKGQueryEngine implements QueryEngine { ); } + /** + * GH #1098 — discover the per-cgId VM DATA graphs `/context/` + * for a context graph. Chain-reconciled / per-cgId-only publishes materialise + * confirmed VM content here rather than in the root content graph. We keep + * ONLY the bare `/context/` data graphs: `discoverGraphsByPrefix` + * already drops `/_meta` + `/staging/`, and the extra `!rest.includes('/')` + * guard excludes any nested per-cgId sub-partition (`…/context//_private`, + * `…/context//_shared_memory`, …) so a VM content read never pulls + * private/SWM rows. + */ + private async discoverContextGraphPerCgIdDataGraphs(contextGraphId: string): Promise { + const base = `did:dkg:context-graph:${contextGraphId}/context/`; + const discovered = await this.discoverGraphsByPrefix(base); + return discovered.filter((g) => { + const rest = g.slice(base.length); + return rest.length > 0 && !rest.includes('/'); + }); + } + /** * Resolve a WM assertion's allocated KA number (`dkg:kaId`) off its lifecycle URN * in `_meta`, for single-graph by-name reads under the uniform layout. Returns diff --git a/packages/query/test/vm-percgid-resolution.test.ts b/packages/query/test/vm-percgid-resolution.test.ts new file mode 100644 index 000000000..df5968c8f --- /dev/null +++ b/packages/query/test/vm-percgid-resolution.test.ts @@ -0,0 +1,64 @@ +/** + * Regression test for GH #1098 (layer 2) — the `verifiable-memory` view must + * resolve the per-cgId VM data graph `did:dkg:context-graph:/context/`. + * + * Chain-driven VM reconcile (and any per-cgId-only materialisation, e.g. a peer + * that subscribed BEFORE publish and recovered via the reconcile sweep) writes + * confirmed data ONLY into the per-cgId graph, never the root content graph. The + * base VM resolution reads only the root content graph + `_verifiable_memory/*`, + * so that data was materialised yet INVISIBLE to a VM query (the observable half + * of #1098). This pins that an unscoped VM read returns per-cgId data while still + * excluding the per-cgId `_meta` and the CG's `_private` partition. + * + * Hermetic: in-memory OxigraphStore, zero mocks, zero chain. + */ +import { describe, expect, it } from 'vitest'; +import { OxigraphStore } from '@origintrail-official/dkg-storage'; +import { DKGQueryEngine } from '../src/dkg-query-engine.js'; + +const CG = 'gh1098-percgid-cg'; +const ONCHAIN = '7'; +const NAME = 'http://schema.org/name'; +const DATA_ENTITY = 'https://example.org/gh1098/data'; +const META_ENTITY = 'https://example.org/gh1098/meta'; +const PRIVATE_ENTITY = 'https://example.org/gh1098/private'; + +function q(subject: string, predicate: string, object: string, graph: string) { + return { subject, predicate, object, graph }; +} + +describe('GH #1098 layer 2 — verifiable-memory view resolves per-cgId data graphs', () => { + it('an unscoped VM read returns data that lives ONLY in /context/, excluding meta/private', async () => { + const store = new OxigraphStore(); + const engine = new DKGQueryEngine(store); + + const perCgIdData = `did:dkg:context-graph:${CG}/context/${ONCHAIN}`; + const perCgIdMeta = `${perCgIdData}/_meta`; + const privateGraph = `did:dkg:context-graph:${CG}/_private`; + + await store.insert([ + // Confirmed VM content — lives ONLY in the per-cgId data graph (where the + // chain-reconcile path promotes it; NOT dual-written to the root graph). + q(DATA_ENTITY, NAME, '"PerCgIdConfirmed"', perCgIdData), + // A schema:name row in the per-cgId `_meta` — a content read must NOT pull + // metadata-graph rows into the result set. + q(META_ENTITY, NAME, '"PerCgIdMetaRow"', perCgIdMeta), + // A schema:name row in the CG's private partition — never surfaced by a + // VM content read (no includePrivate opt-in). + q(PRIVATE_ENTITY, NAME, '"SecretPrivate"', privateGraph), + ]); + + const result = await engine.query( + `SELECT ?s ?o WHERE { ?s <${NAME}> ?o }`, + { contextGraphId: CG, view: 'verifiable-memory' }, + ); + const objects = result.bindings.map((b) => b['o']); + + // The per-cgId confirmed data IS visible (the #1098 layer-2 fix). + expect(objects).toContain('"PerCgIdConfirmed"'); + // The per-cgId `_meta` graph is NOT unioned into a content VM read. + expect(objects).not.toContain('"PerCgIdMetaRow"'); + // The `_private` partition is NOT unioned into a VM read. + expect(objects).not.toContain('"SecretPrivate"'); + }); +}); diff --git a/packages/storage/src/private-store.ts b/packages/storage/src/private-store.ts index f9877a0a8..ba38fa7c5 100644 --- a/packages/storage/src/private-store.ts +++ b/packages/storage/src/private-store.ts @@ -7,6 +7,16 @@ import { import type { TripleStore, Quad } from './triple-store.js'; import type { ContextGraphManager } from './graph-manager.js'; +/** + * GH #1078 — predicate used for the per-(cg,root[,sub]) "current verifiable + * commitment" marker. Stored on a dedicated `urn:dkg:private-commitment-marker:…` + * subject inside the private graph so it is (a) never returned by + * `getPrivateTriples` (which filters on the root subject / its skolem children), + * (b) never collected by the plaintext dedup scan (different predicate), and + * (c) dropped together with the private graph on `dropContextGraph`. + */ +const PRIVATE_COMMITMENT_PRED = 'http://dkg.io/ontology/privateCommitment'; + /** * Manages private triples stored on the local node. Peer-to-peer private * payloads are encrypted before they arrive here; after a node decrypts and @@ -171,6 +181,7 @@ export class PrivateContentStore { rootEntity: string, quads: Quad[], subGraphName?: string, + commitmentId?: string, ): Promise { if (quads.length === 0) return; @@ -178,9 +189,41 @@ export class PrivateContentStore { for (const q of quads) { assertSafePrivateQuad(q); } + if (commitmentId !== undefined && !/^[A-Za-z0-9_.:-]+$/.test(commitmentId)) { + throw new Error(`Unsafe private commitmentId: ${commitmentId.slice(0, 80)}`); + } const graphUri = this.privateGraph(contextGraphId, subGraphName); await this.withGraphWriteLock(graphUri, async () => { + // GH #1078 — verifiable-commitment scoping. When the caller supplies the + // commitment this root actually committed (its `privateMerkleRoot`) AND it + // DIFFERS from the commitment currently recorded for the root, a NEW + // commitment is superseding a stale one (a re-publish, or a draft slice + // replaced by the finalized one): drop the root's prior finalized private + // slice before inserting the new one, so a later hydration / privateData- + // Anchor never returns a different commitment's triples. With NO + // commitmentId the behaviour is unchanged (append + dedup), preserving + // every existing caller and multi-value private predicates. + if (commitmentId !== undefined) { + const markerSubject = privateCommitmentMarkerSubject(contextGraphId, rootEntity, subGraphName); + const current = await this.readPrivateCommitment(graphUri, markerSubject); + if (current !== commitmentId) { + // A different (or first) commitment takes over this root: drop the + // root's prior finalized private slice (a no-op on the very first + // write) so the new commitment fully replaces it, then stamp the new + // commitment marker. Repeated stores under the SAME commitment fall + // through to the append+dedup path below (chunked/retry-safe). + await this.deleteRootPrivateSlice(graphUri, rootEntity); + await this.store.deleteByPattern({ graph: graphUri, subject: markerSubject }); + await this.store.insert([{ + subject: markerSubject, + predicate: PRIVATE_COMMITMENT_PRED, + object: `"${commitmentId}"`, + graph: graphUri, + }]); + } + } + const existingPlainKeys = await this.collectExistingPlaintextKeys( graphUri, quads, @@ -323,11 +366,50 @@ export class PrivateContentStore { ): Promise { assertSafeIri(rootEntity); const graphUri = this.privateGraph(contextGraphId, subGraphName); - await this.store.deleteBySubjectPrefix(graphUri, rootEntity); + await this.deleteRootPrivateSlice(graphUri, rootEntity); const key = this.privateKey(contextGraphId, subGraphName); const entities = this.privateEntities.get(key); if (entities) entities.delete(rootEntity); } + + /** + * Delete exactly ONE root's private slice from `graphUri`: the exact root + * subject plus its skolem children (`/.well-known/genid/…`), the SAME + * shape `getPrivateTriples` reads. We deliberately do NOT use a bare + * `deleteBySubjectPrefix(root)` — RDF root IRIs are not prefix-delimited, so a + * raw prefix would also delete sibling roots that share it (e.g. superseding + * `urn:device:1` would nuke `urn:device:10`'s private triples). The skolem + * prefix IS delimited (`…/.well-known/genid/`), so it is collision-safe. + */ + private async deleteRootPrivateSlice(graphUri: string, rootEntity: string): Promise { + assertSafeIri(rootEntity); + await this.store.deleteByPattern({ graph: graphUri, subject: rootEntity }); + await this.store.deleteBySubjectPrefix(graphUri, `${rootEntity}/.well-known/genid/`); + } + + /** + * GH #1078 — read the commitment id currently recorded for a root's private + * slice (undefined when none has been recorded, i.e. legacy/append-only + * writers). Used to decide whether an incoming commitment supersedes the + * stored slice. + */ + private async readPrivateCommitment( + graphUri: string, + markerSubject: string, + ): Promise { + const result = await this.store.query( + `SELECT ?c WHERE { + GRAPH <${assertSafeIri(graphUri)}> { + <${assertSafeIri(markerSubject)}> <${PRIVATE_COMMITMENT_PRED}> ?c . + } + } LIMIT 1`, + ); + if (result.type !== 'bindings' || result.bindings.length === 0) return undefined; + const raw = result.bindings[0]?.['c']; + if (typeof raw !== 'string') return undefined; + const m = raw.match(/^"([\s\S]*)"(\^\^.*)?$/); + return m ? m[1] : raw; + } } function privateStageSubject( @@ -343,6 +425,23 @@ function privateStageSubject( return subject; } +/** + * GH #1078 — subject for the per-(cg,root[,sub]) commitment marker. Deliberately + * NOT prefixed by the root IRI so `getPrivateTriples`' root-subject filter and + * the `deleteBySubjectPrefix(root)` supersede sweep never touch it. + */ +function privateCommitmentMarkerSubject( + contextGraphId: string, + rootEntity: string, + subGraphName?: string, +): string { + const parts = [contextGraphId, subGraphName ?? '_', rootEntity] + .map((part) => encodeURIComponent(part)); + const subject = `urn:dkg:private-commitment-marker:${parts.join(':')}`; + assertSafeIri(subject); + return subject; +} + function parseLiteral(value: string | undefined): unknown { if (!value) return undefined; try { diff --git a/packages/storage/test/issue-1078-private-layer-scope.test.ts b/packages/storage/test/issue-1078-private-layer-scope.test.ts new file mode 100644 index 000000000..f0147428f --- /dev/null +++ b/packages/storage/test/issue-1078-private-layer-scope.test.ts @@ -0,0 +1,92 @@ +/** + * Issue-liveness repro for GH #1078 — "Private payload storage is not scoped to + * memory layer or verifiable commitment." + * https://github.com/OriginTrail/dkg/issues/1078 + * + * `PrivateContentStore` keys the finalized private partition only by + * `(contextGraphId[, subGraphName])` → `…/_private`. It is NOT split by memory + * layer / KA version the way public state is (WM/SWM/VM each get their own + * graph). So two DISTINCT private commitments for the SAME root — e.g. a stale + * draft slice and the slice the verifiable KA actually committed — land in ONE + * graph and `getPrivateTriples(cg, root)` returns BOTH. A caller that follows a + * `dkg:privateDataAnchor` on a verifiable KA then hydrates triples that a + * different layer/version committed. + * + * This test asserts the CORRECT (post-fix) behaviour, so it is RED today + * (the bug is live) and turns GREEN once the fix lands; it stays red until #1078 is fixed.. Hermetic — in-memory oxigraph only. + */ +import { describe, expect, it } from 'vitest'; +import { OxigraphStore, GraphManager, PrivateContentStore, type Quad } from '../src/index.js'; + +const CG = 'gh1078-cg'; +const ROOT = 'urn:gh1078:device'; + +function priv(predicate: string, object: string): Quad { + return { subject: ROOT, predicate, object, graph: '' }; +} + +describe('GH #1078 — private payload storage must be scoped to the committing layer/commitment', () => { + it( + 'a root hydrates only the authoritative private slice, not a different commitment for the same root', + async () => { + const store = new OxigraphStore(); + const gm = new GraphManager(store); + const pcs = new PrivateContentStore(store, gm); + + // Commitment #1 — an EARLIER private slice for ROOT (e.g. a WM/SWM draft or + // a superseded KA version). The exact authority does not matter; what + // matters is that it is a DIFFERENT private payload committed under the + // same root, identified by its own verifiable-commitment id. + await pcs.storePrivateTriples(CG, ROOT, [priv('https://schema.org/serialNumber', '"OLD-0001"')], undefined, 'commitment-old'); + + // Commitment #2 — the slice the AUTHORITATIVE / verifiable KA actually + // committed for ROOT. This is what a `privateDataAnchor` on the verifiable + // KA should resolve to. A NEW commitment supersedes the stale one for the + // same root, so the stale slice must not survive into hydration. + await pcs.storePrivateTriples(CG, ROOT, [priv('https://schema.org/serialNumber', '"NEW-0002"')], undefined, 'commitment-new'); + + // Hydration for ROOT (the only main-API read path). + const hydrated = await pcs.getPrivateTriples(CG, ROOT); + const serials = hydrated + .filter((q) => q.predicate === 'https://schema.org/serialNumber') + .map((q) => q.object); + + // Control: the authoritative slice IS present (so the negative assertion + // below is meaningful and not vacuously true on an empty read). + expect(serials).toContain('"NEW-0002"'); + + // CORRECT (post-fix): a verifiable-commitment-scoped hydration returns ONLY + // the authoritative slice. Today private storage is layer-blind, so the + // superseded "OLD-0001" leaks back in — exactly the cross-commitment + // hydration #1078 describes. + expect(serials).not.toContain('"OLD-0001"'); + }, + ); + + it( + 'superseding one root does NOT delete a sibling root whose IRI shares its prefix', + async () => { + const store = new OxigraphStore(); + const gm = new GraphManager(store); + const pcs = new PrivateContentStore(store, gm); + + const R1 = 'urn:gh1078:device:1'; + const R10 = 'urn:gh1078:device:10'; // shares the "urn:gh1078:device:1" prefix + + await pcs.storePrivateTriples(CG, R1, [{ subject: R1, predicate: 'https://schema.org/serialNumber', object: '"R1-OLD"', graph: '' }], undefined, 'c1'); + await pcs.storePrivateTriples(CG, R10, [{ subject: R10, predicate: 'https://schema.org/serialNumber', object: '"R10-KEEP"', graph: '' }], undefined, 'c10'); + + // Re-publish R1 under a NEW commitment → supersedes R1's slice. A bare + // prefix delete would also wipe R10 (STRSTARTS("urn:device:1")); the + // exact-root + skolem-prefix delete must leave R10 intact. + await pcs.storePrivateTriples(CG, R1, [{ subject: R1, predicate: 'https://schema.org/serialNumber', object: '"R1-NEW"', graph: '' }], undefined, 'c1-v2'); + + const r10 = (await pcs.getPrivateTriples(CG, R10)).map((q) => q.object); + expect(r10, 'sibling root R10 must survive R1 supersede').toContain('"R10-KEEP"'); + + const r1 = (await pcs.getPrivateTriples(CG, R1)).map((q) => q.object); + expect(r1).toContain('"R1-NEW"'); + expect(r1).not.toContain('"R1-OLD"'); + }, + ); +}); diff --git a/scripts/devnet.sh b/scripts/devnet.sh index d4a457f7a..b873f1ba7 100755 --- a/scripts/devnet.sh +++ b/scripts/devnet.sh @@ -615,7 +615,11 @@ start_node() { rm -f "$node_dir/daemon.pid" log "Starting node $node_num..." - DKG_HOME="$node_dir" DKG_NO_BLUE_GREEN=1 \ + # DKG_WALLETS_NO_MIGRATE=1: this harness writes a plaintext wallets.json with + # RANDOM operational keys and the staking step (cmd_start) re-reads + # wallets[0].privateKey directly, so the daemon must NOT migrate it to an + # encrypted keystore (GH #11). Production daemons omit this and auto-migrate. + DKG_HOME="$node_dir" DKG_NO_BLUE_GREEN=1 DKG_WALLETS_NO_MIGRATE=1 \ node "$REPO_ROOT/packages/cli/dist/cli.js" start --foreground \ > "$node_dir/daemon.log" 2>&1 & local node_pid=$!