@darksol/terminal 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,556 @@
1
+ import { ethers } from 'ethers';
2
+ import { createServer } from 'http';
3
+ import { randomBytes } from 'crypto';
4
+ import { decryptKey, loadWallet, walletExists } from './keystore.js';
5
+ import { getConfig, getRPC } from '../config/store.js';
6
+ import { theme } from '../ui/theme.js';
7
+ import { spinner, kvDisplay, success, error, warn, info } from '../ui/components.js';
8
+ import { showSection, showDivider } from '../ui/banner.js';
9
+ import inquirer from 'inquirer';
10
+
11
+ // ══════════════════════════════════════════════════
12
+ // DARKSOL AGENT SIGNER
13
+ // ══════════════════════════════════════════════════
14
+ //
15
+ // Problem: AI agents (OpenClaw, etc.) need to sign transactions, but
16
+ // exposing private keys to LLMs is dangerous — prompt injection could
17
+ // leak the key. Existing wallets (Bankr, Phantom MCP) don't support
18
+ // x402 payments or real contract signing.
19
+ //
20
+ // Solution: A local signing proxy that:
21
+ // 1. Decrypts the private key in memory ONCE at startup
22
+ // 2. Exposes a REST API for signing operations
23
+ // 3. NEVER returns the private key through any endpoint
24
+ // 4. Validates every transaction before signing
25
+ // 5. Supports spending limits, allowlisted contracts, and operation types
26
+ // 6. Auth via one-time bearer token (generated at startup, shown only in terminal)
27
+ //
28
+ // The agent gets: address, chainId, sign capabilities
29
+ // The agent NEVER gets: private key, mnemonic, keystore
30
+ //
31
+ // ══════════════════════════════════════════════════
32
+
33
+ /**
34
+ * Security policy for the agent signer
35
+ */
36
+ const DEFAULT_POLICY = {
37
+ // Max ETH value per transaction (in ETH)
38
+ maxValuePerTx: 1.0,
39
+
40
+ // Max gas price multiplier (prevents gas drain attacks)
41
+ maxGasMultiplier: 3.0,
42
+
43
+ // Allowed operations
44
+ allowedOps: ['sign_transaction', 'sign_message', 'sign_typed_data', 'get_address', 'get_balance', 'get_chain'],
45
+
46
+ // Contract allowlist (empty = allow all, populated = only these)
47
+ allowlistedContracts: [],
48
+
49
+ // Blocked selectors (known dangerous: transferOwnership, selfdestruct, etc.)
50
+ blockedSelectors: [
51
+ '0xf2fde38b', // transferOwnership(address)
52
+ '0x715018a6', // renounceOwnership()
53
+ '0x00000000', // fallback (raw ETH send blocked by default)
54
+ ],
55
+
56
+ // Daily spending limit (in ETH equivalent)
57
+ dailySpendLimit: 5.0,
58
+
59
+ // Require human confirmation for txs above this value (in ETH)
60
+ confirmAbove: 0.5,
61
+
62
+ // Log all operations
63
+ auditLog: true,
64
+ };
65
+
66
+ /**
67
+ * The Agent Signer — PK-isolated transaction signing service
68
+ */
69
+ export class AgentSigner {
70
+ constructor(walletName, password, opts = {}) {
71
+ this.walletName = walletName;
72
+ this.password = password;
73
+ this.policy = { ...DEFAULT_POLICY, ...opts.policy };
74
+ this.signer = null;
75
+ this.provider = null;
76
+ this.address = null;
77
+ this.chain = null;
78
+ this.server = null;
79
+ this.bearerToken = null;
80
+ this.dailySpent = 0;
81
+ this.dailyResetTime = Date.now();
82
+ this.auditLog = [];
83
+ this.port = opts.port || 18790;
84
+ this.host = opts.host || '127.0.0.1'; // Loopback only!
85
+ }
86
+
87
+ /**
88
+ * Initialize — decrypt key and create signer (key stays in memory only)
89
+ */
90
+ async init() {
91
+ if (!walletExists(this.walletName)) {
92
+ throw new Error(`Wallet "${this.walletName}" not found`);
93
+ }
94
+
95
+ const walletData = loadWallet(this.walletName);
96
+ const privateKey = decryptKey(walletData.keystore, this.password);
97
+ this.chain = walletData.chain || getConfig('chain') || 'base';
98
+ const rpcUrl = getRPC(this.chain);
99
+ this.provider = new ethers.JsonRpcProvider(rpcUrl);
100
+ this.signer = new ethers.Wallet(privateKey, this.provider);
101
+ this.address = this.signer.address;
102
+
103
+ // Generate one-time bearer token
104
+ this.bearerToken = randomBytes(32).toString('hex');
105
+
106
+ // The private key variable goes out of scope here
107
+ // It only lives in the ethers.Wallet instance (this.signer)
108
+ // There is no API endpoint that returns it
109
+
110
+ return this;
111
+ }
112
+
113
+ /**
114
+ * Validate a transaction against the security policy
115
+ */
116
+ validateTransaction(tx) {
117
+ const errors = [];
118
+
119
+ // Check value limit
120
+ if (tx.value) {
121
+ const valueETH = parseFloat(ethers.formatEther(tx.value));
122
+ if (valueETH > this.policy.maxValuePerTx) {
123
+ errors.push(`Value ${valueETH} ETH exceeds limit of ${this.policy.maxValuePerTx} ETH`);
124
+ }
125
+ }
126
+
127
+ // Check daily limit
128
+ const txValue = tx.value ? parseFloat(ethers.formatEther(tx.value)) : 0;
129
+ if (this.dailySpent + txValue > this.policy.dailySpendLimit) {
130
+ errors.push(`Daily spend limit reached (${this.policy.dailySpendLimit} ETH)`);
131
+ }
132
+
133
+ // Check contract allowlist
134
+ if (this.policy.allowlistedContracts.length > 0 && tx.to) {
135
+ if (!this.policy.allowlistedContracts.includes(tx.to.toLowerCase())) {
136
+ errors.push(`Contract ${tx.to} not in allowlist`);
137
+ }
138
+ }
139
+
140
+ // Check blocked selectors
141
+ if (tx.data && tx.data.length >= 10) {
142
+ const selector = tx.data.slice(0, 10).toLowerCase();
143
+ if (this.policy.blockedSelectors.includes(selector)) {
144
+ errors.push(`Function selector ${selector} is blocked by security policy`);
145
+ }
146
+ }
147
+
148
+ // Check gas limits
149
+ if (tx.maxFeePerGas) {
150
+ // Will be validated against current gas price at sign time
151
+ }
152
+
153
+ return errors;
154
+ }
155
+
156
+ /**
157
+ * Sign a transaction (with policy checks)
158
+ */
159
+ async signTransaction(tx) {
160
+ // Reset daily counter if needed
161
+ if (Date.now() - this.dailyResetTime > 86400000) {
162
+ this.dailySpent = 0;
163
+ this.dailyResetTime = Date.now();
164
+ }
165
+
166
+ const errors = this.validateTransaction(tx);
167
+ if (errors.length > 0) {
168
+ this._log('sign_transaction', 'DENIED', { errors, tx: this._sanitizeTx(tx) });
169
+ throw new Error(`Transaction blocked: ${errors.join('; ')}`);
170
+ }
171
+
172
+ // Sign
173
+ const signedTx = await this.signer.signTransaction(tx);
174
+
175
+ // Track spending
176
+ if (tx.value) {
177
+ this.dailySpent += parseFloat(ethers.formatEther(tx.value));
178
+ }
179
+
180
+ this._log('sign_transaction', 'SIGNED', { tx: this._sanitizeTx(tx) });
181
+ return signedTx;
182
+ }
183
+
184
+ /**
185
+ * Send a transaction (sign + broadcast)
186
+ */
187
+ async sendTransaction(tx) {
188
+ const errors = this.validateTransaction(tx);
189
+ if (errors.length > 0) {
190
+ this._log('send_transaction', 'DENIED', { errors, tx: this._sanitizeTx(tx) });
191
+ throw new Error(`Transaction blocked: ${errors.join('; ')}`);
192
+ }
193
+
194
+ const response = await this.signer.sendTransaction(tx);
195
+ const receipt = await response.wait();
196
+
197
+ if (tx.value) {
198
+ this.dailySpent += parseFloat(ethers.formatEther(tx.value));
199
+ }
200
+
201
+ this._log('send_transaction', 'SENT', {
202
+ hash: receipt.hash,
203
+ block: receipt.blockNumber,
204
+ gasUsed: receipt.gasUsed.toString(),
205
+ });
206
+
207
+ return {
208
+ hash: receipt.hash,
209
+ blockNumber: receipt.blockNumber,
210
+ gasUsed: receipt.gasUsed.toString(),
211
+ status: receipt.status,
212
+ };
213
+ }
214
+
215
+ /**
216
+ * Sign a message (EIP-191)
217
+ */
218
+ async signMessage(message) {
219
+ const sig = await this.signer.signMessage(message);
220
+ this._log('sign_message', 'SIGNED', { messagePreview: message.slice(0, 100) });
221
+ return sig;
222
+ }
223
+
224
+ /**
225
+ * Sign typed data (EIP-712)
226
+ */
227
+ async signTypedData(domain, types, value) {
228
+ const sig = await this.signer.signTypedData(domain, types, value);
229
+ this._log('sign_typed_data', 'SIGNED', { domain: domain.name });
230
+ return sig;
231
+ }
232
+
233
+ /**
234
+ * Start the HTTP signing proxy
235
+ */
236
+ async startServer() {
237
+ return new Promise((resolve, reject) => {
238
+ this.server = createServer(async (req, res) => {
239
+ // CORS headers
240
+ res.setHeader('Access-Control-Allow-Origin', 'http://localhost:*');
241
+ res.setHeader('Access-Control-Allow-Methods', 'POST, GET, OPTIONS');
242
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization');
243
+
244
+ if (req.method === 'OPTIONS') {
245
+ res.writeHead(204);
246
+ res.end();
247
+ return;
248
+ }
249
+
250
+ // Auth check
251
+ const auth = req.headers.authorization;
252
+ if (!auth || auth !== `Bearer ${this.bearerToken}`) {
253
+ res.writeHead(401, { 'Content-Type': 'application/json' });
254
+ res.end(JSON.stringify({ error: 'Unauthorized' }));
255
+ this._log('auth', 'DENIED', { ip: req.socket.remoteAddress });
256
+ return;
257
+ }
258
+
259
+ try {
260
+ const body = await this._readBody(req);
261
+ const result = await this._handleRequest(req.url, body);
262
+ res.writeHead(200, { 'Content-Type': 'application/json' });
263
+ res.end(JSON.stringify(result));
264
+ } catch (err) {
265
+ res.writeHead(400, { 'Content-Type': 'application/json' });
266
+ res.end(JSON.stringify({ error: err.message }));
267
+ }
268
+ });
269
+
270
+ this.server.listen(this.port, this.host, () => {
271
+ resolve();
272
+ });
273
+
274
+ this.server.on('error', reject);
275
+ });
276
+ }
277
+
278
+ /**
279
+ * Handle API request routing
280
+ */
281
+ async _handleRequest(url, body) {
282
+ switch (url) {
283
+ case '/address':
284
+ return { address: this.address, chain: this.chain };
285
+
286
+ case '/balance': {
287
+ const balance = await this.provider.getBalance(this.address);
288
+ return {
289
+ address: this.address,
290
+ balance: ethers.formatEther(balance),
291
+ chain: this.chain,
292
+ };
293
+ }
294
+
295
+ case '/chain':
296
+ return { chain: this.chain, rpc: getRPC(this.chain) };
297
+
298
+ case '/sign': {
299
+ const signed = await this.signTransaction(body);
300
+ return { signed };
301
+ }
302
+
303
+ case '/send': {
304
+ const result = await this.sendTransaction(body);
305
+ return result;
306
+ }
307
+
308
+ case '/sign-message': {
309
+ const sig = await this.signMessage(body.message);
310
+ return { signature: sig, address: this.address };
311
+ }
312
+
313
+ case '/sign-typed-data': {
314
+ const sig = await this.signTypedData(body.domain, body.types, body.value);
315
+ return { signature: sig, address: this.address };
316
+ }
317
+
318
+ case '/policy':
319
+ return {
320
+ maxValuePerTx: this.policy.maxValuePerTx,
321
+ dailySpendLimit: this.policy.dailySpendLimit,
322
+ dailySpent: this.dailySpent,
323
+ dailyRemaining: this.policy.dailySpendLimit - this.dailySpent,
324
+ allowlistedContracts: this.policy.allowlistedContracts.length || 'all',
325
+ confirmAbove: this.policy.confirmAbove,
326
+ };
327
+
328
+ case '/audit':
329
+ return { log: this.auditLog.slice(-50) };
330
+
331
+ case '/health':
332
+ return {
333
+ status: 'ok',
334
+ address: this.address,
335
+ chain: this.chain,
336
+ uptime: Math.floor((Date.now() - this.dailyResetTime) / 1000),
337
+ };
338
+
339
+ // SECURITY: No endpoint for /private-key, /key, /export, /mnemonic, /seed
340
+ // These are intentionally absent. The PK lives only in this.signer.
341
+
342
+ default:
343
+ throw new Error(`Unknown endpoint: ${url}`);
344
+ }
345
+ }
346
+
347
+ /**
348
+ * Stop the server
349
+ */
350
+ stop() {
351
+ if (this.server) {
352
+ this.server.close();
353
+ this.server = null;
354
+ }
355
+ }
356
+
357
+ // Internal helpers
358
+
359
+ _readBody(req) {
360
+ return new Promise((resolve) => {
361
+ if (req.method === 'GET') return resolve({});
362
+ let data = '';
363
+ req.on('data', chunk => data += chunk);
364
+ req.on('end', () => {
365
+ try { resolve(JSON.parse(data)); }
366
+ catch { resolve({}); }
367
+ });
368
+ });
369
+ }
370
+
371
+ _sanitizeTx(tx) {
372
+ return {
373
+ to: tx.to,
374
+ value: tx.value ? ethers.formatEther(tx.value) + ' ETH' : '0',
375
+ dataLength: tx.data ? tx.data.length : 0,
376
+ selector: tx.data?.slice(0, 10) || null,
377
+ };
378
+ }
379
+
380
+ _log(operation, status, details = {}) {
381
+ if (!this.policy.auditLog) return;
382
+ this.auditLog.push({
383
+ timestamp: new Date().toISOString(),
384
+ operation,
385
+ status,
386
+ ...details,
387
+ });
388
+ }
389
+ }
390
+
391
+ // ══════════════════════════════════════════════════
392
+ // CLI COMMANDS
393
+ // ══════════════════════════════════════════════════
394
+
395
+ /**
396
+ * Start the agent signer service
397
+ */
398
+ export async function startAgentSigner(walletName, opts = {}) {
399
+ walletName = walletName || getConfig('activeWallet');
400
+ if (!walletName) {
401
+ error('No wallet specified. Use: darksol agent start <wallet-name>');
402
+ return;
403
+ }
404
+
405
+ const { password } = await inquirer.prompt([{
406
+ type: 'password',
407
+ name: 'password',
408
+ message: theme.gold('Wallet password:'),
409
+ mask: '●',
410
+ }]);
411
+
412
+ const spin = spinner('Starting agent signer...').start();
413
+
414
+ try {
415
+ const signer = new AgentSigner(walletName, password, {
416
+ port: opts.port || 18790,
417
+ policy: {
418
+ maxValuePerTx: parseFloat(opts.maxValue || '1.0'),
419
+ dailySpendLimit: parseFloat(opts.dailyLimit || '5.0'),
420
+ allowlistedContracts: opts.allowlist ? opts.allowlist.split(',') : [],
421
+ },
422
+ });
423
+
424
+ await signer.init();
425
+ await signer.startServer();
426
+
427
+ spin.succeed('Agent signer running');
428
+
429
+ console.log('');
430
+ showSection('🔐 DARKSOL AGENT SIGNER');
431
+ kvDisplay([
432
+ ['Status', theme.success('● ACTIVE')],
433
+ ['Wallet', walletName],
434
+ ['Address', signer.address],
435
+ ['Chain', signer.chain],
436
+ ['Endpoint', `http://${signer.host}:${signer.port}`],
437
+ ['Max/TX', `${signer.policy.maxValuePerTx} ETH`],
438
+ ['Daily Limit', `${signer.policy.dailySpendLimit} ETH`],
439
+ ['Contracts', signer.policy.allowlistedContracts.length ? signer.policy.allowlistedContracts.length + ' allowlisted' : 'All allowed'],
440
+ ]);
441
+
442
+ console.log('');
443
+ showSection('BEARER TOKEN (show once — copy now)');
444
+ console.log(theme.accent(` ${signer.bearerToken}`));
445
+
446
+ console.log('');
447
+ showSection('OPENCLAW INTEGRATION');
448
+ console.log(theme.dim(' Add to your OpenClaw config:'));
449
+ console.log('');
450
+ console.log(theme.gold(' tools:'));
451
+ console.log(theme.dim(' darksol-signer:'));
452
+ console.log(theme.dim(` url: http://127.0.0.1:${signer.port}`));
453
+ console.log(theme.dim(` token: ${signer.bearerToken}`));
454
+ console.log('');
455
+
456
+ showSection('API ENDPOINTS');
457
+ const endpoints = [
458
+ ['GET /address', 'Get wallet address (safe)'],
459
+ ['GET /balance', 'Get ETH balance (safe)'],
460
+ ['GET /chain', 'Get active chain (safe)'],
461
+ ['POST /send', 'Sign + broadcast transaction'],
462
+ ['POST /sign', 'Sign transaction (return raw)'],
463
+ ['POST /sign-message', 'Sign EIP-191 message'],
464
+ ['POST /sign-typed-data', 'Sign EIP-712 typed data'],
465
+ ['GET /policy', 'View spending policy'],
466
+ ['GET /audit', 'View audit log'],
467
+ ['GET /health', 'Health check'],
468
+ ];
469
+ endpoints.forEach(([ep, desc]) => {
470
+ console.log(` ${theme.gold(ep.padEnd(26))} ${theme.dim(desc)}`);
471
+ });
472
+
473
+ console.log('');
474
+ showSection('SECURITY');
475
+ console.log(theme.dim(' ✓ Private key NEVER exposed via any endpoint'));
476
+ console.log(theme.dim(' ✓ Loopback-only (127.0.0.1) — not accessible from network'));
477
+ console.log(theme.dim(' ✓ Bearer token auth required for every request'));
478
+ console.log(theme.dim(' ✓ Per-TX value limits + daily spending cap'));
479
+ console.log(theme.dim(' ✓ Contract allowlist support'));
480
+ console.log(theme.dim(' ✓ Dangerous function selectors blocked'));
481
+ console.log(theme.dim(' ✓ Full audit log of all operations'));
482
+ console.log(theme.dim(' ✓ Prompt injection resistant — LLM never sees the key'));
483
+ console.log('');
484
+
485
+ warn('Press Ctrl+C to stop the signer');
486
+ info('The signer runs until you stop it. Your key stays in memory only.');
487
+
488
+ // Keep alive
489
+ await new Promise(() => {});
490
+
491
+ } catch (err) {
492
+ spin.fail('Failed to start agent signer');
493
+ error(err.message);
494
+ }
495
+ }
496
+
497
+ /**
498
+ * Show agent signer documentation
499
+ */
500
+ export function showAgentDocs() {
501
+ showSection('🔐 DARKSOL AGENT SIGNER — SECURITY MODEL');
502
+ console.log('');
503
+
504
+ console.log(theme.gold(' THE PROBLEM'));
505
+ console.log(theme.dim(' AI agents need to sign transactions, but exposing private'));
506
+ console.log(theme.dim(' keys to LLMs is dangerous. Prompt injection attacks could'));
507
+ console.log(theme.dim(' trick the AI into revealing the key. Existing agent wallets'));
508
+ console.log(theme.dim(' (Bankr, Phantom MCP) can\'t execute x402 payments or sign'));
509
+ console.log(theme.dim(' arbitrary contracts in the wild.'));
510
+ console.log('');
511
+
512
+ console.log(theme.gold(' THE SOLUTION'));
513
+ console.log(theme.dim(' DARKSOL Agent Signer is a local signing proxy:'));
514
+ console.log(theme.dim(' 1. You unlock your wallet ONCE with your password'));
515
+ console.log(theme.dim(' 2. The key decrypts into memory (never to disk/API)'));
516
+ console.log(theme.dim(' 3. A local HTTP server exposes signing endpoints'));
517
+ console.log(theme.dim(' 4. AI agents call /send, /sign — never see the key'));
518
+ console.log(theme.dim(' 5. Every TX is validated against your security policy'));
519
+ console.log('');
520
+
521
+ console.log(theme.gold(' WHY IT\'S SAFE'));
522
+ console.log(theme.dim(' ✓ No /private-key endpoint exists — literally no way to extract it'));
523
+ console.log(theme.dim(' ✓ Loopback-only — only your machine can reach it'));
524
+ console.log(theme.dim(' ✓ Bearer token — one-time auth shown only in your terminal'));
525
+ console.log(theme.dim(' ✓ Spending limits — cap per TX and per day'));
526
+ console.log(theme.dim(' ✓ Contract allowlist — restrict which contracts can be called'));
527
+ console.log(theme.dim(' ✓ Blocked selectors — transferOwnership, selfdestruct blocked'));
528
+ console.log(theme.dim(' ✓ Audit log — every operation is recorded'));
529
+ console.log(theme.dim(' ✓ Prompt injection proof — the LLM literally cannot access the key'));
530
+ console.log(theme.dim(' because it doesn\'t exist in any API response'));
531
+ console.log('');
532
+
533
+ console.log(theme.gold(' HOW TO USE WITH OPENCLAW'));
534
+ console.log(theme.dim(' 1. Create a wallet: darksol wallet create agent-wallet'));
535
+ console.log(theme.dim(' 2. Start the signer: darksol agent start agent-wallet'));
536
+ console.log(theme.dim(' 3. Copy the bearer token shown in terminal'));
537
+ console.log(theme.dim(' 4. Add to OpenClaw config or agent\'s TOOLS.md'));
538
+ console.log(theme.dim(' 5. Agent calls http://127.0.0.1:18790/send to sign TXs'));
539
+ console.log(theme.dim(' 6. Agent calls http://127.0.0.1:18790/sign-message for x402'));
540
+ console.log('');
541
+
542
+ console.log(theme.gold(' x402 PAYMENT SIGNING'));
543
+ console.log(theme.dim(' The agent can sign x402 payment authorizations via'));
544
+ console.log(theme.dim(' /sign-typed-data (EIP-712). This enables real x402'));
545
+ console.log(theme.dim(' payments in the wild — something Bankr and Phantom'));
546
+ console.log(theme.dim(' MCP wallets cannot do. Your agent gets a real wallet'));
547
+ console.log(theme.dim(' that can pay for services autonomously.'));
548
+ console.log('');
549
+
550
+ console.log(theme.gold(' SPENDING POLICY'));
551
+ console.log(theme.dim(' --max-value 1.0 Max ETH per transaction (default: 1.0)'));
552
+ console.log(theme.dim(' --daily-limit 5.0 Max ETH per day (default: 5.0)'));
553
+ console.log(theme.dim(' --allowlist 0x.. Only allow these contracts (comma-sep)'));
554
+ console.log(theme.dim(' --port 18790 Signer port (default: 18790)'));
555
+ console.log('');
556
+ }