Skip to content
Merged
Show file tree
Hide file tree
Changes from 11 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
29 changes: 28 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,34 @@ 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. The periodic self-priming sweep remains the
// safety net for a CG whose OnChainId quad hasn't arrived yet.
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) {
if (!sub.subscribed || sub.onChainId) continue;
const resolved = await this.getContextGraphOnChainId(lcg).catch(() => null);
let resolvedNum: bigint | null = null;
try { resolvedNum = resolved ? BigInt(resolved) : null; } catch { resolvedNum = null; }
if (resolvedNum !== null && resolvedNum === targetOnChain) {
this.bindSubscriptionOnChainId(lcg, sub, resolved!);
this.persistContextGraphSubscription(lcg);
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
29 changes: 29 additions & 0 deletions packages/agent/src/dkg-agent-swm-host.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2348,6 +2348,35 @@ 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 the on-chain id for a pre-subscribed PUBLIC member
// CG. A peer that subscribed BEFORE the CG's first publish has
// `sub.onChainId` unset: the chain `ContextGraphCreated` handler only binds
// it for CURATED CGs, and the ACK-signer path (`recordCoreHostedPublicCg`)
// only fires for cores in that publish's storage-ACK set. Left unbound, the
// sweep below skips the CG and the peer is stranded on the unreliable
// one-shot finalization gossip (the ~1/3 materialization + kc-not-synced
// spam in #1098). As soon as the CG's OnChainId quad is local (publisher
// ontology-topic broadcast or durable _meta sync), resolve + bind it so the
// reliable chain-driven reconcile/backfill runs for this peer — the same
// path the late-subscriber (#886) and ACK-signer cores already converge on.
// Best-effort + gated on `!sub.onChainId` so it binds at most once (a later
// id CHANGE would reset the cursor; binding from undefined never does).
if (sub.subscribed && !sub.onChainId) {
Comment thread
Bojan131 marked this conversation as resolved.
try {
// Bind ANY non-null resolved on-chain id. getContextGraphOnChainId
// returns the persisted OnChainId quad (or null) — it never falls back
// to localCgId, so a `resolved === localCgId` match is legitimate for a
// direct CG whose local id IS its numeric on-chain id (e.g. "42"); a
// `!== localCgId` guard would wrongly leave such a sub unbound forever.
const resolved = await this.getContextGraphOnChainId(localCgId);
if (resolved) {
this.bindSubscriptionOnChainId(localCgId, sub, resolved);
this.persistContextGraphSubscription(localCgId);
}
} catch {
/* a store/RPC hiccup on one CG must not abort the whole sweep */
}
}
// Member subscriptions AND Phase D core-hosted public CGs get swept.
if ((!sub.subscribed && !sub.coreHosted) || !sub.onChainId) continue;
void this.reconcileCoalescer.trigger(localCgId);
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, 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((a, b) => (a < b ? -1 : a > b ? 1 : 0));
Comment thread
Bojan131 marked this conversation as resolved.
Outdated

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