Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
43 commits
Select commit Hold shift + click to select a range
fef9640
fix(publisher): drop random fallback wallet + audit Wallet.createRandom
May 3, 2026
199f385
fix(audit): scan tsx/jsx + reword publisher wallet comment
May 4, 2026
8ea17f1
fix(audit): also scan .mts/.cts source files
May 4, 2026
8e416a4
fix(audit): per-hit allowlist + multi-line scan + stable comment refs
May 4, 2026
f0e6917
fix(audit): track string/template-literal state to close // bypass
May 4, 2026
3f22513
fix(audit): stack-based lexer so braces in nested strings can't close…
May 4, 2026
45cb799
fix(publisher): require explicit publisher signing key
May 5, 2026
ff796b3
test(agent): require publisher keys for publish paths
May 5, 2026
f210e12
fix(publisher): support adapter-backed signing
May 5, 2026
d25cc58
fix(publisher): keep tentative publish compatibility
May 5, 2026
d64248e
fix(publisher): skip token estimate for local tentative publish
May 6, 2026
5cbaec4
fix(publisher): restore no-chain tentative publishing
May 6, 2026
2c5c289
test(publisher): update no-wallet phase expectation
May 6, 2026
c34c82b
fix(publisher): fail closed for evm publish without signer
May 6, 2026
5a8355f
fix(agent): preserve adapter-backed publisher signing
May 6, 2026
fae36ce
fix(publisher): retry adapter signer resolution
May 6, 2026
95f6238
fix(publisher): bind adapter signer to publish tx
May 6, 2026
18f079d
test(chain): document signer-pool parity exemptions
May 6, 2026
8e6dcee
fix(publisher): keep adapter signer per operation
May 6, 2026
e6005f2
test(publisher): align adapter-backed tentative publish phases
May 6, 2026
cccf06c
fix(publisher): defer adapter signer ownership
May 6, 2026
8fc382e
fix(agent): preserve adapter operational key fallback
May 6, 2026
c779b52
fix(agent): gate legacy op-key publisher fallback
May 6, 2026
8333e80
fix(publisher): only require signer for on-chain publish
May 6, 2026
feacb6c
fix(publisher): handle legacy adapter attribution
May 6, 2026
956e506
fix(publisher): soften adapter signer failures
May 6, 2026
b15b0f9
fix(publisher): require real update attribution
May 6, 2026
9318f04
fix(chain): keep signer lock internal
May 6, 2026
4c41f8b
fix(publisher): preserve tentative non-v10 publishes
May 6, 2026
30f16ed
fix(publisher): precompute tokens for lazy v10 adapters
May 6, 2026
f91048e
fix(audit): close createRandom parser bypasses
May 6, 2026
2b9dc8c
fix(publisher): preserve explicit signer fallbacks
May 6, 2026
324131d
fix(publisher): resolve adapter attribution fallbacks
May 6, 2026
2cb7833
fix(publisher): gate V10 fallback on readiness
May 6, 2026
edb9392
fix(audit): close createRandom alias gaps
May 6, 2026
986d84b
fix(publisher): defer legacy update attribution failures
May 6, 2026
77f91d6
fix(agent): prefer adapter single-signer publishers
May 6, 2026
2751758
fix(publisher): require chain update attribution
May 6, 2026
3191fd4
fix(agent): avoid reserving publish signers in read paths
May 6, 2026
dddcef2
fix(publisher): report definitive update rejections
May 6, 2026
7a71ed4
fix(publisher): avoid tentative signer reservations
May 6, 2026
cdeea87
fix(agent): verify adapter publisher signer before fallback
May 6, 2026
e857d3b
fix(agent): share adapter operational authority resolution
May 6, 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
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -91,6 +91,21 @@ jobs:
node-version-file: .nvmrc
cache: pnpm

# Cheap (~300 ms) audit that bans `Wallet.createRandom()` outside a
# small allowlist of intentional call sites. See
# `scripts/audit-create-random.mjs` for the incident this prevents
# from regressing — pre-PR-#366 `ensureProfile` used the anti-pattern
# to destroy nine testnet admin keys at registration time.
- name: Audit Wallet.createRandom usage
run: node scripts/audit-create-random.mjs

# Unit tests for the audit lexer itself (string/template-literal
# awareness, comment stripping, etc.). Catches regressions in the
# bypass-resistance the audit relies on — e.g. the PR #371 fix where
# `//` inside a string literal silently blanked real code after it.
- name: Test audit-create-random lexer
run: node --test scripts/audit-create-random.test.mjs

- name: Install dependencies
run: pnpm install --frozen-lockfile

Expand Down
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,12 @@ All notable changes to the DKG V9 node are documented here. The format is based

## [Unreleased]

### Fixed
- **Publisher no longer auto-mints an ephemeral signing wallet** (`packages/publisher/src/dkg-publisher.ts`): `DKGPublisher` constructed without `publisherPrivateKey` previously generated `ethers.Wallet.createRandom()` whenever chain was enabled and used it to sign on-chain publish digests, ACK self-signatures, and authorship proofs. Signatures were unverifiable (signed by a throw-away key the caller never saw, attributed via `publisherAddress` to a different address). The constructor now leaves `publisherWallet` undefined, rejects zero/mismatched `publisherAddress` values, and publish/update fail before emitting publisher-attributed output unless a real signing key exists.

### Added
- **`scripts/audit-create-random.mjs`** + CI gate: bans new `Wallet.createRandom()` use in `packages/*/src/**` outside three explicitly justified call sites (`op-wallets.ts` first-run wallet bootstrap, `agent-keystore.ts` custodial chat-agent registration, `evm-module/utils/helpers.ts` deploy script). Same anti-pattern destroyed nine testnet admin keys in May 2026 via the pre-PR-#366 `ensureProfile` random-and-discard path; audit runs in <300 ms on every CI build.

---

## [10.0.0-rc.4] - 2026-05-04
Expand Down
211 changes: 200 additions & 11 deletions packages/agent/src/dkg-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -452,6 +452,11 @@ export interface DKGAgentConfig {
chainAdapter?: ChainAdapter;
/** Private key for the V10 ACK signer. When omitted, falls back to chainConfig.operationalKeys[0]. */
ackSignerKey?: string;
/**
* Publisher EVM address used when publish signing is delegated to the
* ChainAdapter instead of an in-process publisherPrivateKey.
*/
publisherAddress?: string;
/**
* EVM chain configuration. If omitted, publishing won't have on-chain finality.
* `adminPrivateKey` is the private key for the profile admin wallet used
Expand Down Expand Up @@ -482,6 +487,154 @@ export interface DKGAgentConfig {
contextGraphMembershipStore?: ContextGraphMembershipStore;
}

function normalizeAdapterPublisherAddress(value: unknown): string | undefined {
if (typeof value !== 'string' || !ethers.isAddress(value)) return undefined;
const address = ethers.getAddress(value);
return address === ethers.ZeroAddress ? undefined : address;
}

function recoverCompactSigner(message: Uint8Array, compact: { r: Uint8Array; vs: Uint8Array }): string {
const signature = ethers.Signature.from({
r: ethers.hexlify(compact.r),
yParityAndS: ethers.hexlify(compact.vs),
}).serialized;
return ethers.verifyMessage(message, signature);
}

function adapterOperationalPrivateKeyAddress(chain: ChainAdapter): string | undefined {
const operationalKeyGetter = (chain as unknown as { getOperationalPrivateKey?: () => unknown })
.getOperationalPrivateKey;
if (typeof operationalKeyGetter !== 'function') return undefined;
try {
const privateKey = operationalKeyGetter.call(chain);
return typeof privateKey === 'string' && privateKey.length > 0
? privateKeyAddress(privateKey)
: undefined;
} catch {
return undefined;
}
}

function adapterHasOperationalPrivateKey(chain: ChainAdapter): boolean {
return adapterOperationalPrivateKeyAddress(chain) !== undefined;
}

async function adapterGenericSignMessageMatchesAddress(
chain: ChainAdapter,
expectedAddress: string,
): Promise<boolean> {
if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return false;
const normalized = normalizeAdapterPublisherAddress(expectedAddress);
if (!normalized) return false;

try {
const challenge = ethers.getBytes(ethers.id(`dkg-agent:chain-signer-probe:${normalized.toLowerCase()}`));
const compact = await chain.signMessage(challenge);
const recovered = normalizeAdapterPublisherAddress(recoverCompactSigner(challenge, compact));
return recovered?.toLowerCase() === normalized.toLowerCase();
} catch {
return false;
}
}

async function adapterAdvertisesPublisherSigner(chain: ChainAdapter): Promise<boolean> {
const hasReservingOrMultiAddressProbe = typeof chain.getAuthorizedPublisherAddress === 'function' ||
typeof (chain as unknown as { getSignerAddresses?: unknown }).getSignerAddresses === 'function';
const hasSingleAddressProbe = typeof (chain as unknown as { getSignerAddress?: unknown }).getSignerAddress === 'function' ||
Boolean(normalizeAdapterPublisherAddress((chain as unknown as { signerAddress?: unknown }).signerAddress));
const hasAnyAddressProbe = hasReservingOrMultiAddressProbe || hasSingleAddressProbe;

if (typeof chain.signMessageAs === 'function') return hasAnyAddressProbe;
if (adapterHasOperationalPrivateKey(chain)) return true;
if (typeof chain.signMessage !== 'function') return false;

const advertisedAddress = await inferAdapterPublisherAddress(chain, undefined, {
includeReservingPublisherProbe: false,
includeGenericSignMessageProbe: false,
});
if (!advertisedAddress) return false;
return adapterGenericSignMessageMatchesAddress(chain, advertisedAddress);
}

function privateKeyAddress(privateKey: string | undefined): string | undefined {
if (!privateKey) return undefined;
try {
return normalizeAdapterPublisherAddress(new ethers.Wallet(privateKey).address);
} catch {
return undefined;
}
}

async function inferAdapterPublisherAddress(
chain: ChainAdapter,
contextGraphId?: bigint,
options?: {
includeReservingPublisherProbe?: boolean;
includeGenericSignMessageProbe?: boolean;
},
): Promise<string | undefined> {
if (
options?.includeReservingPublisherProbe !== false &&
contextGraphId !== undefined &&
typeof chain.getAuthorizedPublisherAddress === 'function'
) {
try {
const address = normalizeAdapterPublisherAddress(
await chain.getAuthorizedPublisherAddress(contextGraphId),
);
if (address) return address;
} catch {
// Best-effort probe; the publisher resolver retries on later publish/update attempts.
}
}

const signerAddressGetter = (chain as unknown as { getSignerAddress?: () => unknown }).getSignerAddress;
if (typeof signerAddressGetter === 'function') {
try {
const address = normalizeAdapterPublisherAddress(
await Promise.resolve(signerAddressGetter.call(chain)),
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: This adds async getSignerAddress() support here, but the agent still reads getSignerAddress() synchronously in getChainPublishAuthorityAddress() during curated context-graph registration. A custom adapter that follows this new async pattern will publish fine but can still fail registerContextGraph() with the "without chain signer introspection" path for non-default curators. Either keep getSignerAddress() sync-only, or update that registration guard to await async results too.

);
if (address) return address;
} catch {
// Best-effort probe; fall through to broader adapter surfaces.
}
}

const signerAddresses = (chain as unknown as { getSignerAddresses?: () => unknown }).getSignerAddresses;
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: inferAdapterPublisherAddress() no longer probes getSignerAddress(), even though the agent already treats that as the standard single-signer hook elsewhere. Any custom adapter that exposes only getSignerAddress() will now fail address inference and hit PublisherWalletRequiredError despite being able to report its signer. Consider checking getSignerAddress() before falling back to signMessage().

if (typeof signerAddresses === 'function') {
try {
const advertised = await Promise.resolve(signerAddresses.call(chain));
if (Array.isArray(advertised)) {
for (const value of advertised) {
const address = normalizeAdapterPublisherAddress(value);
if (address) return address;
}
}
} catch {
// Best-effort probe; the publisher resolver retries on later publish/update attempts.
}
}

const signerAddress = normalizeAdapterPublisherAddress(
(chain as unknown as { signerAddress?: unknown }).signerAddress,
);
if (signerAddress) return signerAddress;

const adapterOperationalAddress = adapterOperationalPrivateKeyAddress(chain);
if (adapterOperationalAddress) return adapterOperationalAddress;

if (options?.includeGenericSignMessageProbe === false) return undefined;
if (chain.chainId === 'none' || typeof chain.signMessage !== 'function') return undefined;

try {
const challenge = ethers.getBytes(ethers.id('dkg-agent:publisher-address-probe'));
const compact = await chain.signMessage(challenge);
return normalizeAdapterPublisherAddress(recoverCompactSigner(challenge, compact));
} catch {
return undefined;
}
}

/**
* High-level facade that ties together all DKG agent capabilities:
* identity, networking, publishing, querying, discovery, and messaging.
Expand Down Expand Up @@ -680,12 +833,31 @@ export class DKGAgent {
const node = new DKGNode(nodeConfig);
const workspaceOwnedEntities = new Map<string, Map<string, string>>();
const writeLocks = new Map<string, Promise<void>>();
const legacyAdapterOperationalKey = opKeys?.[0];
const legacyAdapterOperationalAddress = privateKeyAddress(legacyAdapterOperationalKey);
const configuredPublisherAddress = normalizeAdapterPublisherAddress(config.publisherAddress);
const publisherAddressMatchesLegacyKey = Boolean(
configuredPublisherAddress &&
legacyAdapterOperationalAddress &&
configuredPublisherAddress.toLowerCase() === legacyAdapterOperationalAddress.toLowerCase(),
);
const adapterCanPublishFromAdvertisedSigner = await adapterAdvertisesPublisherSigner(chain);
const useLegacyAdapterOperationalKeyFallback = Boolean(
config.chainAdapter &&
legacyAdapterOperationalKey &&
!adapterCanPublishFromAdvertisedSigner &&
(!configuredPublisherAddress || publisherAddressMatchesLegacyKey),
);
const publisher = new DKGPublisher({
store,
chain,
eventBus,
keypair,
publisherPrivateKey: opKeys?.[0],
publisherPrivateKey: useLegacyAdapterOperationalKeyFallback ? legacyAdapterOperationalKey : undefined,
publisherAddress: config.publisherAddress,
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: DKGAgent.create() no longer passes opKeys?.[0] into DKGPublisher. That regresses setups which provide a custom chainAdapter plus chainConfig.operationalKeys, but whose adapter does not yet implement the new publisher-address/signing probes: publishes now fail with PublisherWalletRequiredError even though the agent already has a usable local signing key. Keep the old private-key fallback when the adapter cannot resolve a signer.

publisherAddressResolver: config.publisherAddress || useLegacyAdapterOperationalKeyFallback
? undefined
: (contextGraphId?: bigint) => inferAdapterPublisherAddress(chain, contextGraphId),
sharedMemoryOwnedEntities: workspaceOwnedEntities,
writeLocks,
});
Expand Down Expand Up @@ -4376,7 +4548,7 @@ export class DKGAgent {
);
}
const publishAuthority = publishPolicy === EVM_PUBLISH_CURATED
? this.getChainPublishAuthorityAddress()
? await this.getChainPublishAuthorityAddress(id)
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: This now derives curated registration authority through getChainPublishAuthorityAddress(), which falls back to generic chain.signMessage() inference. That breaks adapters where signMessage() uses a different key than the actual publish path (for example, publish still falls back to chainConfig.operationalKeys[0]): registration can reject the real curator or store the wrong publishAuthority. Keep registration authority resolution aligned with the publish fallback rules instead of treating bare signMessage() as authoritative here.

: undefined;
if (
publishPolicy === EVM_PUBLISH_CURATED
Expand Down Expand Up @@ -7103,16 +7275,33 @@ export class DKGAgent {
&& ownerAddress.toLowerCase() === this.defaultAgentAddress.toLowerCase();
}

private getChainPublishAuthorityAddress(): string | undefined {
const chainWithSigner = this.chain as unknown as {
getSignerAddress?: () => string;
signerAddress?: string;
};
const rawAddress = chainWithSigner.getSignerAddress?.() ?? chainWithSigner.signerAddress;
if (rawAddress && ethers.isAddress(rawAddress)) {
return ethers.getAddress(rawAddress);
private async getChainPublishAuthorityAddress(contextGraphId?: string): Promise<string | undefined> {
const configuredPublisherAddress = normalizeAdapterPublisherAddress(this.config.publisherAddress);
if (configuredPublisherAddress) return configuredPublisherAddress;

const legacyAdapterOperationalKey = this.config.chainConfig?.operationalKeys?.[0];
Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: this only looks at chainConfig.operationalKeys[0], but create() now also derives the legacy publisher key from chainAdapter.getOperationalPrivateKey(). For adapters that expose only getOperationalPrivateKey() (for example the new OperationalKeyOnlyPublishChainAdapter path), publish uses that fallback key while curated registration sees no publish authority here, so registerContextGraph() can reject a valid owner or register without the actual authority address. Reuse the same derived adapter key/address here (or persist it on the agent) so registration and publish stay consistent.

const legacyAdapterOperationalAddress = privateKeyAddress(legacyAdapterOperationalKey);
if (
this.config.chainAdapter &&
legacyAdapterOperationalAddress &&
!(await adapterAdvertisesPublisherSigner(this.chain))
) {
return legacyAdapterOperationalAddress;
}
return undefined;

let publisherContextGraphId: bigint | undefined;
try {
const parsed = BigInt(contextGraphId ?? '');
if (parsed > 0n) publisherContextGraphId = parsed;
} catch {
// Local descriptive CG ids cannot be used as adapter context hints.
}
// This mirrors the publisher resolver, including the adapter-only
// `getOperationalPrivateKey()` fallback used by custom ChainAdapters.
return inferAdapterPublisherAddress(this.chain, publisherContextGraphId, {
includeReservingPublisherProbe: false,
includeGenericSignMessageProbe: false,
});
}

/**
Expand Down
Loading
Loading