feat(kernel): custody seam — execute() takes an injected Signer, not a raw key

Prep for on-chain session-key custody (J's choice: on-chain over MPC). The x402
2.25 exact scheme validates a payer-WITH-CODE via ERC-1271 isValidSignature
(SDK: isERC7702Delegation/getERC7702DelegateAddress/verifyHashSignatureWithCode),
so an EIP-7702-delegated EOA is a valid x402 payer — on-chain session keys are
compatible with x402 exact. The ClientEvmSigner interface is just {address,
signTypedData}, so custody choice reduces to WHICH Signer we inject.

- kernel.execute(challenge, auth, signer: Signer, target) — was spendingKey:string
- Signer = ClientEvmSigner; rawKeySigner(key) = custody v0 (raw EOA, viem-lazy)
- gateway builds  — spendingKey
  path unchanged for existing callers; on-chain signer plugs in via deps.signer
- No kernel API change (the freeze holds). 33 tests: adds a seam test proving an
  injected signer REPLACES spendingKey (garbage key + valid signer still signs).

Next (needs the delegate contract): an EIP-7702 session-key Signer whose spend
cap/allowlist/expiry are enforced ON-CHAIN — the leash USDC itself obeys.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01MknoCuiQ6a2qV1jcirjFPA
This commit is contained in:
Claw
2026-09-06 12:22:51 -05:00
parent af5961e149
commit d4484508ad
3 changed files with 49 additions and 10 deletions
+23 -7
View File
@@ -16,9 +16,25 @@
* swapping it never changes this API.
*/
import { decodePaymentRequiredHeader, decodePaymentResponseHeader } from '@x402/core/http';
import type { ClientEvmSigner } from '@x402/evm';
import { createHash } from 'node:crypto';
import { checkSpend, type SpendPolicy, type SpendState } from '../safety/policy.ts';
/**
* The signing capability the kernel needs — and the ONLY thing custody choice changes. Today it's
* a raw EOA key (rawKeySigner). Tomorrow it's a session key whose authority is enforced ON-CHAIN by
* an EIP-7702 delegate contract: same interface, so execute() never learns which it holds. x402's
* exact scheme validates a payer-with-code via ERC-1271 (`isValidSignature`), so a 7702-delegated
* EOA is a payer like any other — the delegate, not this process, is the final arbiter of a spend.
*/
export type Signer = ClientEvmSigner;
/** Custody v0: a raw EOA private key. Async so viem stays lazy (loaded only when we actually pay). */
export async function rawKeySigner(privateKey: string): Promise<Signer> {
const { privateKeyToAccount } = await import('viem/accounts');
return privateKeyToAccount(privateKey as `0x${string}`);
}
/** sha256 of the exact PAYMENT-REQUIRED header, truncated to 128 bits — the binding id. */
export function challengeId(header: string): string {
return createHash('sha256').update(header).digest('hex').slice(0, 32);
@@ -72,20 +88,20 @@ export type Execution =
| { ok: false; reason: string };
/**
* Stage 3 — pay the EXACT authorized challenge. Refuses unless the authorization was granted AND
* its challengeId matches this challenge (structural binding), then submits the payment with a
* bound fetch that aborts if the target's re-fetched 402 differs from the authorized one (TOCTOU).
* Stage 3 — pay the EXACT authorized challenge with the injected Signer. Refuses unless the
* authorization was granted AND its challengeId matches this challenge (structural binding), then
* submits the payment with a bound fetch that aborts if the target's re-fetched 402 differs from
* the authorized one (TOCTOU). The Signer is a capability, not a key — swapping raw-EOA for an
* on-chain session-key signer never touches this function.
*/
export async function execute(c: Challenge, auth: Authorization, spendingKey: string, target: string): Promise<Execution> {
export async function execute(c: Challenge, auth: Authorization, signer: Signer, target: string): Promise<Execution> {
if (!auth.approved) return { ok: false, reason: 'not_authorized' };
if (auth.challengeId !== c.challengeId) return { ok: false, reason: 'authorization_challenge_mismatch' };
const { x402Client, wrapFetchWithPayment } = await import('@x402/fetch');
const { registerExactEvmScheme } = await import('@x402/evm/exact/client');
const { privateKeyToAccount } = await import('viem/accounts');
const account = privateKeyToAccount(spendingKey as `0x${string}`);
const client = new x402Client();
registerExactEvmScheme(client, { signer: account });
registerExactEvmScheme(client, { signer });
// Bound fetch: re-digest any 402 the payment library re-fetches and abort if it isn't the
// authorized challenge — defense in depth on top of the structural challengeId check above.
+7 -3
View File
@@ -8,13 +8,16 @@
* production this is where a delegated / spend-limited session key or an isolated signing service
* goes — the agent authorizes the gateway up to policy limits, never hands over a master key.
*/
import { discoverPayment, authorize, execute } from '../kernel/kernel.ts';
import { discoverPayment, authorize, execute, rawKeySigner, type Signer } from '../kernel/kernel.ts';
import type { SpendLedger } from './ledger.ts';
export interface GatewayDeps {
ledger: SpendLedger;
/** The agent's spending key (test-only here). */
/** The agent's spending key (raw-EOA custody v0, test-only here). Ignored if `signer` is set. */
spendingKey: string;
/** Injected signing capability. When present it REPLACES spendingKey — this is the seam an
* on-chain session-key (EIP-7702 delegate) signer plugs into without changing the kernel. */
signer?: Signer;
nowSec?: () => number;
}
@@ -71,7 +74,8 @@ async function enforceAndPayLocked(agentId: string, targetUrl: string, deps: Gat
return { allowed: false, reason: auth.reason, amountAtomic: c.amount, recipient: c.payTo, state: fmtState(state) };
}
const out = await execute(c, auth, deps.spendingKey, targetUrl);
const signer = deps.signer ?? (await rawKeySigner(deps.spendingKey));
const out = await execute(c, auth, signer, targetUrl);
if (!out.ok) {
deps.ledger.record(agentId, { at: now, recipient: c.payTo, amountAtomic: c.amount, network: c.network, asset: c.asset, allowed: false, reason: out.reason, resourceUrl: targetUrl, challengeDigest: c.challengeId });
return { allowed: false, reason: out.reason, amountAtomic: c.amount, recipient: c.payTo, state: fmtState(state) };
+19
View File
@@ -6,6 +6,7 @@
*/
import { test, expect } from 'bun:test';
import { encodePaymentRequiredHeader } from '@x402/core/http';
import { privateKeyToAccount } from 'viem/accounts';
import { createSpendLedger } from '../src/safety/ledger.ts';
import { enforceAndPay } from '../src/safety/gateway.ts';
@@ -66,6 +67,24 @@ test('challenge SWAPPED between approval and signing → challenge_toctou_blocke
}
});
test('custody seam: an injected signer REPLACES spendingKey (garbage key + valid signer still signs)', async () => {
const same = challengeHeader('10000', RCV);
const server = Bun.serve({ port: 0, fetch: () => new Response('{}', { status: 402, headers: { 'PAYMENT-REQUIRED': same } }) });
try {
const ledger = createSpendLedger();
ledger.setPolicy(policyFor('seam'));
const signer = privateKeyToAccount(TEST_KEY as `0x${string}`);
// spendingKey is unparseable: if the gateway used it (seam broken) rawKeySigner would throw.
// Reaching a payment-stage reason instead proves the injected signer was used to sign.
const r = await enforceAndPay('seam', `http://127.0.0.1:${server.port}/`, { ledger, spendingKey: '0xNOTAKEY', signer });
expect(r.allowed).toBe(false); // stub isn't a real facilitator → fails at settle, not at key parse
expect(r.reason).not.toBe('challenge_toctou_blocked');
expect(r.reason).not.toContain('NOTAKEY'); // never even looked at the raw key
} finally {
server.stop(true);
}
});
test('control: byte-identical challenge on re-fetch is NOT blocked by the binding (gets past to sign)', async () => {
const same = challengeHeader('10000', RCV);
const server = Bun.serve({ port: 0, fetch: () => new Response('{}', { status: 402, headers: { 'PAYMENT-REQUIRED': same } }) });