Skip to content
Merged
Show file tree
Hide file tree
Changes from 14 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
25 changes: 24 additions & 1 deletion packages/agent/src/dkg-agent-lifecycle.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1511,7 +1511,30 @@ export class LifecycleSyncMethods extends DKGAgentBase {
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
if (!localCgId) {
// GH #1098 — a pre-subscribed PUBLIC member peer may not have
// bound this CG's on-chain id yet (only curated CGs bind on the
// ContextGraphCreated event; ACK-signers bind via the storage-ACK
// hook). 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.
let targetOnChain: bigint | null = null;
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
try { targetOnChain = BigInt(onChainId); } catch { targetOnChain = null; }
if (targetOnChain !== null) {
for (const [lcg, sub] of this.subscribedContextGraphs) {
const bound = await this.selfPrimeSubscriptionOnChainId(lcg, sub, targetOnChain);
Comment thread
Bojan131 marked this conversation as resolved.
Outdated
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);
break;
}
}
}
return; // 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.
Expand Down
45 changes: 45 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,57 @@ 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;
}

/**
* 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