Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
15 commits
Select commit Hold shift + click to select a range
4138664
fix(agent): deterministic per-root tokenId in chain-driven VM reconci…
Bojan131 Jun 18, 2026
802f95d
fix(publisher): async-lift carries an inline-encryption path for priv…
Bojan131 Jun 18, 2026
0adc230
fix(storage): scope private payload storage by verifiable commitment …
Bojan131 Jun 18, 2026
d13ff93
fix(agent): encrypt operational wallet private keys at rest (#11)
Bojan131 Jun 18, 2026
2cd60a3
fix(agent): bind onChainId for pre-subscribed public CGs in VM reconc…
Bojan131 Jun 18, 2026
c976b41
fix(query): verifiable-memory view resolves per-cgId data graphs (#10…
Bojan131 Jun 18, 2026
3cbe483
Merge remote-tracking branch 'origin/main' into fix/p0-pre-mainnet-is…
Bojan131 Jun 18, 2026
5b40242
fix(publisher): scope #1121 encrypted-inline requirement to on-chain …
Bojan131 Jun 18, 2026
28a1161
fix: resolve otReviewAgent findings on PR #1228 (2 bugs + coverage)
Bojan131 Jun 18, 2026
bda3a9c
fix: resolve otReviewAgent round-2 findings on PR #1228 (2 bugs + con…
Bojan131 Jun 18, 2026
e560438
fix(publisher): only skip the FAIL-CLOSED encryption default on chain…
Bojan131 Jun 18, 2026
8a062d0
fix: resolve otReviewAgent round-3 findings on PR #1228 (1 bug + dedup)
Bojan131 Jun 18, 2026
15ce354
test(#936): pin the exact canonical root→tokenId map (guard vs oxigra…
Bojan131 Jun 18, 2026
6cf9299
test(#1098): cover the KACG nudge targeted self-prime (match binds, m…
Bojan131 Jun 18, 2026
1cf195e
fix: address bug-bot round-6 findings (KoOvA/KoOvD/KoOvH)
Bojan131 Jun 18, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 3 additions & 8 deletions packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
});
Expand Down
101 changes: 101 additions & 0 deletions packages/agent/src/dkg-agent-swm-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2348,12 +2348,113 @@ export class SwmHostModeMethods extends DKGAgentBase {
async runVmReconcileSweep(this: DKGAgent): Promise<void> {
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) {
Comment thread
Bojan131 marked this conversation as resolved.
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<string | null> {
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<string | null> {
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
Expand Down
26 changes: 23 additions & 3 deletions packages/agent/src/finalization-handler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand Down Expand Up @@ -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
// `<cg>/_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);
Expand Down
174 changes: 165 additions & 9 deletions packages/agent/src/op-wallets.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -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 {
Comment thread
Bojan131 marked this conversation as resolved.
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
Expand All @@ -34,7 +64,7 @@ export async function loadOpWallets(

try {
const raw = await readFile(filePath, 'utf-8');
const parsed = JSON.parse(raw) as Partial<OpWalletsConfig> | 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');
Expand All @@ -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();
Expand All @@ -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;
Expand All @@ -85,8 +154,18 @@ export function generateWallets(count: number): OpWalletsConfig {

async function saveOpWallets(dataDir: string, config: OpWalletsConfig): Promise<void> {
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);
}

Expand All @@ -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<Buffer | undefined> {
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<Buffer> {
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;
}
Loading
Loading