Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 commits
Commits
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
23 changes: 10 additions & 13 deletions packages/evm-wallet-experiment/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ For a deeper explanation of the components and data flow, see [How It Works](./d
- **Peer signing has no interactive approval for message/typed-data requests.** Transaction signing over peer requests is now disabled and peer-connected wallets must use delegation redemption for sends, but message and typed-data peer signing still execute immediately without an approval prompt.
- **`revokeDelegation()` and hybrid redemption require a bundler or peer relay.** Hybrid accounts submit on-chain `disableDelegation` / redemption via ERC-4337 UserOps; configure a bundler (and optional paymaster). **Stateless 7702** accounts use a direct EIP-1559 transaction instead; only the JSON-RPC provider must be configured. **Away wallets without a bundler** relay delegation redemptions to the home wallet via CapTP (requires the home wallet to be online). If the on-chain transaction fails, the local delegation status is not changed.
- **Mnemonic encryption is optional.** The keyring vat can encrypt the mnemonic at rest using AES-256-GCM with a PBKDF2-derived key. Pass a `password` and `salt` to `initializeKeyring()` to enable encryption. Without a password, the mnemonic is stored in plaintext. When encrypted, the keyring starts in a locked state on daemon restart and must be unlocked with `unlockKeyring(password)` before signing operations work.
- **Throwaway keyring needs secure entropy.** `initializeKeyring({ type: 'throwaway' })` requires either `crypto.getRandomValues` in the runtime or caller-provided entropy via `{ type: 'throwaway', entropy: '0x...' }`. Under SES lockdown (where `crypto` is unavailable inside vat compartments), the caller must generate 32 bytes of entropy externally and pass it in.

## Architecture

Expand Down Expand Up @@ -126,9 +125,7 @@ import { makeWalletClusterConfig } from '@ocap/evm-wallet-experiment';
// 1. Launch the wallet subcluster with a throwaway keyring
const config = makeWalletClusterConfig({ bundleBaseUrl: '/bundles' });
const { rootKref } = await kernel.launchSubcluster(config);
// Under SES lockdown, pass entropy generated outside the vat:
const entropy = `0x${Buffer.from(crypto.getRandomValues(new Uint8Array(32))).toString('hex')}`;
await coordinator.initializeKeyring({ type: 'throwaway', entropy });
await coordinator.initializeKeyring({ type: 'throwaway' });

// 2. Connect to the home kernel via the OCAP URL
// This automatically:
Expand Down Expand Up @@ -315,15 +312,15 @@ const userOpHash = await coordinator.redeemDelegation({

### Coordinator -- Lifecycle

| Method | Description |
| -------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bootstrap(vats, services)` | Called by the kernel during subcluster launch. Wires up vat references. |
| `initializeKeyring(options)` | Initialize the keyring vat. Options: `{ type: 'srp', mnemonic, password?, salt? }` or `{ type: 'throwaway', entropy? }`. Under SES lockdown, pass `entropy` (32-byte hex) for throwaway keys. When `password` is provided for SRP, the mnemonic is encrypted at rest (requires a random `salt` hex string). |
| `unlockKeyring(password)` | Unlock an encrypted keyring after daemon restart. Required before any signing operations when the mnemonic was encrypted with a password. |
| `isKeyringLocked()` | Returns `true` if the keyring is encrypted and has not been unlocked yet. |
| `configureProvider(chainConfig)` | Configure the provider vat with an RPC URL and chain ID. |
| `connectExternalSigner(signer)` | Connect an external signing backend (e.g., MetaMask). |
| `configureBundler(config)` | Configure the ERC-4337 bundler. Accepts `{ bundlerUrl, chainId, entryPoint?, usePaymaster?, sponsorshipPolicyId? }`. |
| Method | Description |
| -------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `bootstrap(vats, services)` | Called by the kernel during subcluster launch. Wires up vat references. |
| `initializeKeyring(options)` | Initialize the keyring vat. Options: `{ type: 'srp', mnemonic, password?, salt? }` or `{ type: 'throwaway' }`. When `password` is provided for SRP, the mnemonic is encrypted at rest (requires a random `salt` hex string). |
| `unlockKeyring(password)` | Unlock an encrypted keyring after daemon restart. Required before any signing operations when the mnemonic was encrypted with a password. |
| `isKeyringLocked()` | Returns `true` if the keyring is encrypted and has not been unlocked yet. |
| `configureProvider(chainConfig)` | Configure the provider vat with an RPC URL and chain ID. |
| `connectExternalSigner(signer)` | Connect an external signing backend (e.g., MetaMask). |
| `configureBundler(config)` | Configure the ERC-4337 bundler. Accepts `{ bundlerUrl, chainId, entryPoint?, usePaymaster?, sponsorshipPolicyId? }`. |

### Coordinator -- Signing

Expand Down
5 changes: 2 additions & 3 deletions packages/evm-wallet-experiment/docs/setup-guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -561,11 +561,10 @@ yarn ocap daemon exec launchSubcluster '{"config": { ... }}'

### 3e. Initialize with a throwaway key

The away wallet gets a throwaway key (for signing UserOps within delegations). Under SES lockdown, `crypto.getRandomValues` is unavailable in vat compartments, so you must generate entropy externally:
The away wallet gets a throwaway key (for signing UserOps within delegations):

```bash
ENTROPY="0x$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")"
yarn ocap daemon queueMessage ko4 initializeKeyring "[{\"type\": \"throwaway\", \"entropy\": \"$ENTROPY\"}]"
yarn ocap daemon queueMessage ko4 initializeKeyring '[{"type":"throwaway"}]'
```

### 3f. Connect to the home wallet
Expand Down
11 changes: 2 additions & 9 deletions packages/evm-wallet-experiment/scripts/setup-away.sh
Original file line number Diff line number Diff line change
Expand Up @@ -373,7 +373,7 @@ CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$AWAY_RPC_H
},
keyring: {
bundleSpec: bd + '/keyring-vat.bundle',
globals: ['TextEncoder', 'TextDecoder']
globals: ['TextEncoder', 'TextDecoder', 'crypto']
},
provider: {
bundleSpec: bd + '/provider-vat.bundle',
Expand Down Expand Up @@ -407,14 +407,7 @@ ok "Subcluster launched — coordinator: $ROOT_KREF"
# ---------------------------------------------------------------------------

info "Initializing throwaway keyring..."
# Generate 32 bytes of entropy outside the SES compartment (crypto.getRandomValues
# is unavailable inside vats). The entropy is passed to the keyring vat which uses
# it as the private key for the throwaway account.
ENTROPY="0x$(node -e "process.stdout.write(require('crypto').randomBytes(32).toString('hex'))")"
INIT_ARGS=$(ENTROPY="$ENTROPY" node -e "
process.stdout.write(JSON.stringify([{ type: 'throwaway', entropy: process.env.ENTROPY }]));
")
daemon_qm --quiet "$ROOT_KREF" initializeKeyring "$INIT_ARGS" >/dev/null
daemon_qm --quiet "$ROOT_KREF" initializeKeyring '[{"type":"throwaway"}]' >/dev/null
ok "Throwaway keyring initialized"

info "Verifying accounts..."
Expand Down
2 changes: 1 addition & 1 deletion packages/evm-wallet-experiment/scripts/setup-home.sh
Original file line number Diff line number Diff line change
Expand Up @@ -338,7 +338,7 @@ CONFIG=$(BUNDLE_DIR="$BUNDLE_DIR" DM="$DELEGATION_MANAGER" RPC_HOST="$RPC_HOST"
},
keyring: {
bundleSpec: bd + '/keyring-vat.bundle',
globals: ['TextEncoder', 'TextDecoder']
globals: ['TextEncoder', 'TextDecoder', 'crypto']
Comment thread
cursor[bot] marked this conversation as resolved.
},
provider: {
bundleSpec: bd + '/provider-vat.bundle',
Expand Down
15 changes: 12 additions & 3 deletions packages/evm-wallet-experiment/src/cluster-config.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,19 @@ describe('cluster-config', () => {
bundleBaseUrl: BUNDLE_BASE_URL,
});

const baseGlobals = ['TextEncoder', 'TextDecoder'];
for (const vatName of ['keyring', 'provider', 'delegator']) {
const providerConfig = config.vats.provider as { globals?: string[] };
expect(providerConfig.globals).toStrictEqual([
'TextEncoder',
'TextDecoder',
]);

for (const vatName of ['keyring', 'delegator']) {
const vatConfig = config.vats[vatName] as { globals?: string[] };
expect(vatConfig.globals).toStrictEqual(baseGlobals);
expect(vatConfig.globals).toStrictEqual([
'TextEncoder',
'TextDecoder',
'crypto',
]);
}

const coordConfig = config.vats.coordinator as { globals?: string[] };
Expand Down
4 changes: 2 additions & 2 deletions packages/evm-wallet-experiment/src/cluster-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ export function makeWalletClusterConfig(
? {
delegator: {
bundleSpec: `${bundleBaseUrl}/delegator-vat.bundle`,
globals: ['TextEncoder', 'TextDecoder'],
globals: ['TextEncoder', 'TextDecoder', 'crypto'],
},
}
: {
Expand All @@ -58,7 +58,7 @@ export function makeWalletClusterConfig(
},
keyring: {
bundleSpec: `${bundleBaseUrl}/keyring-vat.bundle`,
globals: ['TextEncoder', 'TextDecoder'],
globals: ['TextEncoder', 'TextDecoder', 'crypto'],
},
provider: {
bundleSpec: `${bundleBaseUrl}/provider-vat.bundle`,
Expand Down
6 changes: 0 additions & 6 deletions packages/evm-wallet-experiment/src/lib/bundler-client.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,6 @@
/**
* Bundler client using raw fetch for ERC-4337 interactions.
*
* Avoids viem's createClient/http which use Math.random() (blocked under
* SES lockdown). All methods are simple JSON-RPC calls over fetch.
*
* @module lib/bundler-client
*/

Expand Down Expand Up @@ -188,9 +185,6 @@ async function bundlerRpcOnce(
/**
* Create a bundler client for ERC-4337 operations.
*
* Uses raw fetch instead of viem's createClient to avoid Math.random()
* usage that is blocked under SES lockdown.
*
* @param config - Bundler configuration.
* @returns A bundler client with ERC-4337 actions.
*/
Expand Down
15 changes: 2 additions & 13 deletions packages/evm-wallet-experiment/src/lib/delegation.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,21 +52,10 @@ describe('lib/delegation', () => {
expect(generate()).not.toBe(generate());
});

it('two generators produce independent sequences', () => {
it('produces distinct salts from independent generators', () => {
const gen1 = makeSaltGenerator();
const gen2 = makeSaltGenerator();
// Each generator's counter is independent — advance gen1 several times
// without touching gen2 and verify gen2 still produces valid salts.
gen1();
gen1();
gen1();
expect(gen2()).toMatch(/^0x[\da-f]{64}$/iu);
});

it('accepts entropy without throwing', () => {
const entropy = '0xdeadbeef' as `0x${string}`;
const generate = makeSaltGenerator(entropy);
expect(generate()).toMatch(/^0x[\da-f]{64}$/iu);
expect(gen1()).not.toBe(gen2());
});
});

Expand Down
44 changes: 6 additions & 38 deletions packages/evm-wallet-experiment/src/lib/delegation.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,52 +56,20 @@ export type SaltGenerator = () => Hex;
/**
* Create a salt generator for delegation uniqueness.
*
* Prefers crypto.getRandomValues when available. In SES compartments
* where crypto is not endowed, falls back to a closure-local counter
* hashed with optional caller-supplied entropy. Each call to
* makeSaltGenerator produces an independent counter, so two vat instances
* each get their own sequence rather than sharing module-level state.
*
* @param entropy - Optional caller-supplied entropy hex string. When provided
* and crypto is unavailable, mixed into the counter hash so that separate
* vat instances produce distinct salts even though both start at counter 1.
* @returns A salt generator function.
* @returns A salt generator function backed by `crypto.getRandomValues`.
*/
export function makeSaltGenerator(entropy?: Hex): SaltGenerator {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
if (globalThis.crypto?.getRandomValues) {
return () => {
const bytes = new Uint8Array(32);
// eslint-disable-next-line n/no-unsupported-features/node-builtins
globalThis.crypto.getRandomValues(bytes);
return toHex(bytes);
};
}

// SES fallback: unique per generator lifetime but not cryptographically random.
// The salt only needs uniqueness, not unpredictability.
let counter = 0;
if (entropy !== undefined) {
return () => {
counter += 1;
return keccak256(
encodePacked(['bytes', 'uint256'], [entropy, BigInt(counter)]),
);
};
}
export function makeSaltGenerator(): SaltGenerator {
return () => {
counter += 1;
return keccak256(encodePacked(['uint256'], [BigInt(counter)]));
const bytes = new Uint8Array(32);
// eslint-disable-next-line n/no-unsupported-features/node-builtins
globalThis.crypto.getRandomValues(bytes);
return toHex(bytes);
};
}

/**
* Generate a random salt for delegation uniqueness.
*
* Uses a module-level counter as the SES fallback. Prefer
* {@link makeSaltGenerator} when creating delegations in a vat, since it
* gives each vat instance an independent counter.
*
* @returns A hex-encoded random salt.
*/
export const generateSalt: SaltGenerator = makeSaltGenerator();
Expand Down
42 changes: 1 addition & 41 deletions packages/evm-wallet-experiment/src/lib/keyring.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mnemonicToAccount } from 'viem/accounts';
import { describe, it, expect, vi } from 'vitest';
import { describe, it, expect } from 'vitest';

import { makeKeyring, generateMnemonicPhrase } from './keyring.ts';

Expand Down Expand Up @@ -110,46 +110,6 @@ describe('lib/keyring', () => {
const keyring = makeKeyring({ type: 'throwaway' });
expect(keyring.getMnemonic()).toBeUndefined();
});

it('requires secure randomness when creating throwaway keys without entropy', () => {
vi.stubGlobal('crypto', undefined);
try {
expect(() => makeKeyring({ type: 'throwaway' })).toThrow(
'Throwaway keyring requires crypto.getRandomValues or caller-provided entropy',
);
} finally {
vi.unstubAllGlobals();
}
});

it('accepts caller-provided entropy for throwaway keys', () => {
vi.stubGlobal('crypto', undefined);
try {
const entropy =
'0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80';
const keyring = makeKeyring({
type: 'throwaway',
entropy: entropy as `0x${string}`,
});
const accounts = keyring.getAccounts();
expect(accounts).toHaveLength(1);
expect(accounts[0]).toMatch(/^0x[\da-f]{40}$/u);
} finally {
vi.unstubAllGlobals();
}
});

it.each(['0xshort', '0x', 'not-hex', `0x${'ff'.repeat(33)}`])(
'rejects invalid entropy: %s',
(badEntropy) => {
expect(() =>
makeKeyring({
type: 'throwaway',
entropy: badEntropy as `0x${string}`,
}),
).toThrow('Invalid entropy');
},
);
});
});

Expand Down
31 changes: 8 additions & 23 deletions packages/evm-wallet-experiment/src/lib/keyring.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,16 +8,21 @@ import {
import type { HDAccount, LocalAccount } from 'viem/accounts';

import type { EncryptedMnemonicData } from './mnemonic-crypto.ts';
import type { Address, Hex } from '../types.ts';
import type { Address } from '../types.ts';

const harden = globalThis.harden ?? (<T>(value: T): T => value);

/**
* Options for initializing a keyring.
*
* Throwaway keyrings are intentionally ephemeral: each call to `makeKeyring`
* generates a fresh private key, so the key does not survive a vat restart.
* Baggage only persists `{ type: 'throwaway' }`; callers that need key
* stability across restarts must use `type: 'srp'`.
*/
export type KeyringInitOptions =
| { type: 'srp'; mnemonic: string; addressIndex?: number }
| { type: 'throwaway'; entropy?: Hex };
| { type: 'throwaway' };

/**
* Encrypted keyring init data stored in baggage when a password is used.
Expand Down Expand Up @@ -59,27 +64,7 @@ export function makeKeyring(options: KeyringInitOptions): Keyring {
const startIndex = options.addressIndex ?? 0;
deriveAccountInternal(startIndex);
} else {
let privateKey: Hex;
if (options.entropy) {
Comment thread
cursor[bot] marked this conversation as resolved.
// Caller-provided entropy (for SES compartments without crypto global).
// The caller is responsible for providing cryptographically secure bytes.
if (!/^0x[\da-f]{64}$/iu.test(options.entropy)) {
throw new Error(
'Invalid entropy: expected a 0x-prefixed 32-byte hex string' +
` (got ${String(options.entropy).length} chars)`,
);
}
privateKey = options.entropy;
} else {
// eslint-disable-next-line n/no-unsupported-features/node-builtins
if (!globalThis.crypto?.getRandomValues) {
throw new Error(
'Throwaway keyring requires crypto.getRandomValues or caller-provided entropy',
);
}
privateKey = generatePrivateKey();
}
const account = privateKeyToAccount(privateKey);
const account = privateKeyToAccount(generatePrivateKey());
accounts.set(account.address.toLowerCase() as Address, account);
}

Expand Down
3 changes: 0 additions & 3 deletions packages/evm-wallet-experiment/src/lib/provider.ts
Original file line number Diff line number Diff line change
Expand Up @@ -123,9 +123,6 @@ async function jsonRpcOnce(
/**
* Create a JSON-RPC provider for the given chain.
*
* Uses raw fetch instead of viem's createPublicClient to avoid
* Math.random() usage that is blocked under SES lockdown.
*
* @param config - The chain configuration.
* @returns The provider instance.
*/
Expand Down
Loading
Loading