@elytro/cli 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.
Files changed (2) hide show
  1. package/dist/index.js +206 -80
  2. package/package.json +1 -1
package/dist/index.js CHANGED
@@ -363,6 +363,8 @@ var STORAGE_KEY = "keyring";
363
363
  var KeyringService = class {
364
364
  store;
365
365
  vault = null;
366
+ /** Vault key kept in memory for re-encryption operations (addOwner, switchOwner). */
367
+ vaultKey = null;
366
368
  constructor(store) {
367
369
  this.store = store;
368
370
  }
@@ -373,9 +375,9 @@ var KeyringService = class {
373
375
  }
374
376
  /**
375
377
  * Create a brand-new vault with one owner.
376
- * Called during `elytro init`. Encrypts with device key.
378
+ * Called during `elytro init`. Encrypts with vault key.
377
379
  */
378
- async createNewOwner(deviceKey) {
380
+ async createNewOwner(vaultKey) {
379
381
  const privateKey = generatePrivateKey();
380
382
  const account = privateKeyToAccount(privateKey);
381
383
  const owner = { id: account.address, key: privateKey };
@@ -383,26 +385,32 @@ var KeyringService = class {
383
385
  owners: [owner],
384
386
  currentOwnerId: account.address
385
387
  };
386
- const encrypted = await encryptWithKey(deviceKey, vault);
388
+ const encrypted = await encryptWithKey(vaultKey, vault);
387
389
  await this.store.save(STORAGE_KEY, encrypted);
388
390
  this.vault = vault;
391
+ this.vaultKey = new Uint8Array(vaultKey);
389
392
  return account.address;
390
393
  }
391
394
  // ─── Unlock / Access ────────────────────────────────────────────
392
395
  /**
393
- * Decrypt the vault with the device key.
394
- * Called automatically by context at CLI startup.
396
+ * Decrypt the vault with the vault key.
397
+ * Called automatically by context at CLI startup via SecretProvider.
395
398
  */
396
- async unlock(deviceKey) {
399
+ async unlock(vaultKey) {
397
400
  const encrypted = await this.store.load(STORAGE_KEY);
398
401
  if (!encrypted) {
399
402
  throw new Error("Keyring not initialized. Run `elytro init` first.");
400
403
  }
401
- this.vault = await decryptWithKey(deviceKey, encrypted);
404
+ this.vault = await decryptWithKey(vaultKey, encrypted);
405
+ this.vaultKey = new Uint8Array(vaultKey);
402
406
  }
403
- /** Lock the vault, clearing decrypted keys from memory. */
407
+ /** Lock the vault, clearing decrypted keys and vault key from memory. */
404
408
  lock() {
405
409
  this.vault = null;
410
+ if (this.vaultKey) {
411
+ this.vaultKey.fill(0);
412
+ this.vaultKey = null;
413
+ }
406
414
  }
407
415
  get isUnlocked() {
408
416
  return this.vault !== null;
@@ -441,22 +449,22 @@ var KeyringService = class {
441
449
  return privateKeyToAccount(key);
442
450
  }
443
451
  // ─── Multi-owner management ─────────────────────────────────────
444
- async addOwner(deviceKey) {
452
+ async addOwner() {
445
453
  this.ensureUnlocked();
446
454
  const privateKey = generatePrivateKey();
447
455
  const account = privateKeyToAccount(privateKey);
448
456
  this.vault.owners.push({ id: account.address, key: privateKey });
449
- await this.persistVault(deviceKey);
457
+ await this.persistVault();
450
458
  return account.address;
451
459
  }
452
- async switchOwner(ownerId, deviceKey) {
460
+ async switchOwner(ownerId) {
453
461
  this.ensureUnlocked();
454
462
  const exists = this.vault.owners.some((o) => o.id === ownerId);
455
463
  if (!exists) {
456
464
  throw new Error(`Owner ${ownerId} not found in vault.`);
457
465
  }
458
466
  this.vault.currentOwnerId = ownerId;
459
- await this.persistVault(deviceKey);
467
+ await this.persistVault();
460
468
  }
461
469
  // ─── Export / Import (password-based for portability) ───────────
462
470
  /**
@@ -469,18 +477,20 @@ var KeyringService = class {
469
477
  }
470
478
  /**
471
479
  * Import vault from a password-encrypted backup.
472
- * Decrypts with the backup password, then re-encrypts with device key.
480
+ * Decrypts with the backup password, then re-encrypts with vault key.
473
481
  */
474
- async importVault(encrypted, password2, deviceKey) {
482
+ async importVault(encrypted, password2, vaultKey) {
475
483
  const vault = await decrypt(password2, encrypted);
476
484
  this.vault = vault;
477
- const reEncrypted = await encryptWithKey(deviceKey, vault);
485
+ this.vaultKey = new Uint8Array(vaultKey);
486
+ const reEncrypted = await encryptWithKey(vaultKey, vault);
478
487
  await this.store.save(STORAGE_KEY, reEncrypted);
479
488
  }
480
- // ─── Rekey (device key rotation) ───────────────────────────────
481
- async rekey(newDeviceKey) {
489
+ // ─── Rekey (vault key rotation) ───────────────────────────────
490
+ async rekey(newVaultKey) {
482
491
  this.ensureUnlocked();
483
- await this.persistVault(newDeviceKey);
492
+ this.vaultKey = new Uint8Array(newVaultKey);
493
+ await this.persistVault();
484
494
  }
485
495
  // ─── Internal ───────────────────────────────────────────────────
486
496
  getCurrentKey() {
@@ -498,9 +508,10 @@ var KeyringService = class {
498
508
  throw new Error("Keyring is locked. Run `elytro init` first.");
499
509
  }
500
510
  }
501
- async persistVault(deviceKey) {
511
+ async persistVault() {
502
512
  if (!this.vault) throw new Error("No vault to persist.");
503
- const encrypted = await encryptWithKey(deviceKey, this.vault);
513
+ if (!this.vaultKey) throw new Error("No vault key available for re-encryption.");
514
+ const encrypted = await encryptWithKey(this.vaultKey, this.vault);
504
515
  await this.store.save(STORAGE_KEY, encrypted);
505
516
  }
506
517
  };
@@ -555,11 +566,36 @@ function resolveBundler(chainId, pimlicoKey) {
555
566
  return PUBLIC_BUNDLER[chainId] ?? PUBLIC_BUNDLER[11155420];
556
567
  }
557
568
  var CHAIN_META = [
558
- { id: 1, name: "Ethereum", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://etherscan.io" },
559
- { id: 10, name: "Optimism", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://optimistic.etherscan.io" },
560
- { id: 42161, name: "Arbitrum One", nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 }, blockExplorer: "https://arbiscan.io" },
561
- { id: 11155111, name: "Sepolia", nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, blockExplorer: "https://sepolia.etherscan.io" },
562
- { id: 11155420, name: "Optimism Sepolia", nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 }, blockExplorer: "https://sepolia-optimism.etherscan.io" }
569
+ {
570
+ id: 1,
571
+ name: "Ethereum",
572
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
573
+ blockExplorer: "https://etherscan.io"
574
+ },
575
+ {
576
+ id: 10,
577
+ name: "Optimism",
578
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
579
+ blockExplorer: "https://optimistic.etherscan.io"
580
+ },
581
+ {
582
+ id: 42161,
583
+ name: "Arbitrum One",
584
+ nativeCurrency: { name: "Ether", symbol: "ETH", decimals: 18 },
585
+ blockExplorer: "https://arbiscan.io"
586
+ },
587
+ {
588
+ id: 11155111,
589
+ name: "Sepolia",
590
+ nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
591
+ blockExplorer: "https://sepolia.etherscan.io"
592
+ },
593
+ {
594
+ id: 11155420,
595
+ name: "Optimism Sepolia",
596
+ nativeCurrency: { name: "Sepolia ETH", symbol: "ETH", decimals: 18 },
597
+ blockExplorer: "https://sepolia-optimism.etherscan.io"
598
+ }
563
599
  ];
564
600
  function buildChains(alchemyKey, pimlicoKey) {
565
601
  return CHAIN_META.map((meta) => ({
@@ -2064,53 +2100,120 @@ var SecurityHookService = class {
2064
2100
  }
2065
2101
  };
2066
2102
 
2067
- // src/utils/deviceKey.ts
2068
- import { webcrypto as webcrypto2 } from "crypto";
2069
- import { readFile as readFile2, writeFile as writeFile2, stat, chmod } from "fs/promises";
2070
- import { join as join2 } from "path";
2071
- var DEVICE_KEY_FILE = ".device-key";
2072
- var KEY_LENGTH2 = 32;
2073
- var REQUIRED_MODE = 384;
2074
- function generateDeviceKey() {
2075
- return webcrypto2.getRandomValues(new Uint8Array(KEY_LENGTH2));
2076
- }
2077
- async function saveDeviceKey(dataDir, key) {
2078
- validateDeviceKey(key);
2079
- const path = keyPath(dataDir);
2080
- await writeFile2(path, key, { mode: REQUIRED_MODE });
2081
- await chmod(path, REQUIRED_MODE);
2082
- }
2083
- async function loadDeviceKey(dataDir) {
2084
- const path = keyPath(dataDir);
2085
- try {
2086
- const st = await stat(path);
2087
- const mode = st.mode & 511;
2088
- if (mode !== REQUIRED_MODE) {
2089
- throw new Error(
2090
- `Device key has insecure permissions (${modeStr(mode)}). Expected 600. Fix with: chmod 600 ${path}`
2091
- );
2103
+ // src/providers/keychainProvider.ts
2104
+ import { execFile } from "child_process";
2105
+ import { promisify } from "util";
2106
+ var execFileAsync = promisify(execFile);
2107
+ var KeychainProvider = class {
2108
+ name = "macos-keychain";
2109
+ service = "elytro-wallet";
2110
+ account = "vault-key";
2111
+ async available() {
2112
+ if (process.platform !== "darwin") return false;
2113
+ try {
2114
+ await execFileAsync("security", ["help"], { timeout: 5e3 });
2115
+ return true;
2116
+ } catch {
2117
+ return process.platform === "darwin";
2092
2118
  }
2093
- const buf = await readFile2(path);
2094
- const key = new Uint8Array(buf);
2095
- validateDeviceKey(key);
2096
- return key;
2097
- } catch (err) {
2098
- if (err.code === "ENOENT") {
2099
- return null;
2119
+ }
2120
+ async store(secret) {
2121
+ validateKeyLength(secret);
2122
+ const b64 = Buffer.from(secret).toString("base64");
2123
+ try {
2124
+ await execFileAsync("security", [
2125
+ "add-generic-password",
2126
+ "-U",
2127
+ "-s",
2128
+ this.service,
2129
+ "-a",
2130
+ this.account,
2131
+ "-w",
2132
+ b64
2133
+ ]);
2134
+ } catch (err) {
2135
+ throw new Error(`Failed to store vault key in Keychain: ${err.message}`);
2100
2136
  }
2101
- throw err;
2102
2137
  }
2103
- }
2104
- function validateDeviceKey(key) {
2105
- if (key.length !== KEY_LENGTH2) {
2106
- throw new Error(`Invalid device key: expected ${KEY_LENGTH2} bytes, got ${key.length}.`);
2138
+ async load() {
2139
+ try {
2140
+ const { stdout } = await execFileAsync("security", [
2141
+ "find-generic-password",
2142
+ "-s",
2143
+ this.service,
2144
+ "-a",
2145
+ this.account,
2146
+ "-w"
2147
+ ]);
2148
+ const trimmed = stdout.trim();
2149
+ if (!trimmed) return null;
2150
+ const key = Buffer.from(trimmed, "base64");
2151
+ if (key.length !== 32) {
2152
+ throw new Error(`Keychain vault key has invalid length: expected 32 bytes, got ${key.length}.`);
2153
+ }
2154
+ return new Uint8Array(key);
2155
+ } catch (err) {
2156
+ const msg = err.message || "";
2157
+ if (msg.includes("could not be found") || msg.includes("SecKeychainSearchCopyNext")) {
2158
+ return null;
2159
+ }
2160
+ throw new Error(`Failed to load vault key from Keychain: ${msg}`);
2161
+ }
2162
+ }
2163
+ async delete() {
2164
+ try {
2165
+ await execFileAsync("security", ["delete-generic-password", "-s", this.service, "-a", this.account]);
2166
+ } catch {
2167
+ }
2168
+ }
2169
+ };
2170
+ function validateKeyLength(key) {
2171
+ if (key.length !== 32) {
2172
+ throw new Error(`Invalid vault key: expected 32 bytes, got ${key.length}.`);
2107
2173
  }
2108
2174
  }
2109
- function keyPath(dataDir) {
2110
- return join2(dataDir, DEVICE_KEY_FILE);
2111
- }
2112
- function modeStr(mode) {
2113
- return "0o" + mode.toString(8).padStart(3, "0");
2175
+
2176
+ // src/providers/envVarProvider.ts
2177
+ var ENV_KEY = "ELYTRO_VAULT_SECRET";
2178
+ var EnvVarProvider = class {
2179
+ name = "env-var";
2180
+ async available() {
2181
+ return !!process.env[ENV_KEY];
2182
+ }
2183
+ async store(_secret) {
2184
+ throw new Error(
2185
+ "EnvVarProvider is read-only. Cannot store vault key in an environment variable. Use a persistent provider (macOS Keychain) or store the secret manually."
2186
+ );
2187
+ }
2188
+ async load() {
2189
+ const raw = process.env[ENV_KEY];
2190
+ if (!raw) return null;
2191
+ delete process.env[ENV_KEY];
2192
+ const key = Buffer.from(raw, "base64");
2193
+ if (key.length !== 32) {
2194
+ throw new Error(
2195
+ `${ENV_KEY} has invalid length: expected 32 bytes (base64), got ${key.length}. The value must be a base64-encoded 256-bit key.`
2196
+ );
2197
+ }
2198
+ return new Uint8Array(key);
2199
+ }
2200
+ async delete() {
2201
+ delete process.env[ENV_KEY];
2202
+ }
2203
+ };
2204
+
2205
+ // src/providers/resolveProvider.ts
2206
+ async function resolveProvider() {
2207
+ const keychainProvider = new KeychainProvider();
2208
+ const envProvider = new EnvVarProvider();
2209
+ const initProvider = await keychainProvider.available() ? keychainProvider : null;
2210
+ let loadProvider = null;
2211
+ if (await keychainProvider.available()) {
2212
+ loadProvider = keychainProvider;
2213
+ } else if (await envProvider.available()) {
2214
+ loadProvider = envProvider;
2215
+ }
2216
+ return { initProvider, loadProvider };
2114
2217
  }
2115
2218
 
2116
2219
  // src/context.ts
@@ -2125,9 +2228,13 @@ async function createAppContext() {
2125
2228
  const defaultChain = chain.currentChain;
2126
2229
  walletClient.initForChain(defaultChain);
2127
2230
  await sdk.initForChain(defaultChain);
2128
- const deviceKey = await loadDeviceKey(store.dataDir);
2129
- if (deviceKey && await keyring.isInitialized()) {
2130
- await keyring.unlock(deviceKey);
2231
+ const { loadProvider } = await resolveProvider();
2232
+ if (loadProvider && await keyring.isInitialized()) {
2233
+ const vaultKey = await loadProvider.load();
2234
+ if (vaultKey) {
2235
+ await keyring.unlock(vaultKey);
2236
+ vaultKey.fill(0);
2237
+ }
2131
2238
  }
2132
2239
  const account = new AccountService({
2133
2240
  store,
@@ -2148,10 +2255,11 @@ async function createAppContext() {
2148
2255
  }
2149
2256
  }
2150
2257
  }
2151
- return { store, keyring, chain, sdk, walletClient, account, deviceKey };
2258
+ return { store, keyring, chain, sdk, walletClient, account, secretProvider: loadProvider };
2152
2259
  }
2153
2260
 
2154
2261
  // src/commands/init.ts
2262
+ import { webcrypto as webcrypto2 } from "crypto";
2155
2263
  import ora from "ora";
2156
2264
 
2157
2265
  // src/utils/display.ts
@@ -2219,13 +2327,31 @@ function registerInitCommand(program2, ctx) {
2219
2327
  heading("Initialize Elytro Wallet");
2220
2328
  const spinner = ora("Setting up wallet...").start();
2221
2329
  try {
2222
- const deviceKey = generateDeviceKey();
2223
- await saveDeviceKey(ctx.store.dataDir, deviceKey);
2224
- await ctx.keyring.createNewOwner(deviceKey);
2225
- ctx.deviceKey = deviceKey;
2330
+ const vaultKey = webcrypto2.getRandomValues(new Uint8Array(32));
2331
+ const { initProvider } = await resolveProvider();
2332
+ if (initProvider) {
2333
+ await initProvider.store(vaultKey);
2334
+ spinner.text = `Vault key stored in ${initProvider.name}.`;
2335
+ } else {
2336
+ spinner.stop();
2337
+ const b64 = Buffer.from(vaultKey).toString("base64");
2338
+ console.log("");
2339
+ warn("No persistent secret provider available (not on macOS).");
2340
+ warn("Save the following vault secret \u2014 it will NOT be shown again:");
2341
+ console.log("");
2342
+ console.log(` ELYTRO_VAULT_SECRET="${b64}"`);
2343
+ console.log("");
2344
+ info("Hint", "Set this environment variable before running any elytro command.");
2345
+ spinner.start("Creating wallet...");
2346
+ }
2347
+ await ctx.keyring.createNewOwner(vaultKey);
2348
+ vaultKey.fill(0);
2226
2349
  spinner.succeed("Wallet initialized.");
2227
2350
  console.log("");
2228
2351
  info("Data", ctx.store.dataDir);
2352
+ if (initProvider) {
2353
+ info("Secret Provider", initProvider.name);
2354
+ }
2229
2355
  console.log("");
2230
2356
  success("Run `elytro account create --chain <chainId>` to create your first smart account.");
2231
2357
  } catch (err) {
@@ -2257,7 +2383,7 @@ init_sponsor();
2257
2383
  function registerAccountCommand(program2, ctx) {
2258
2384
  const account = program2.command("account").description("Manage smart accounts");
2259
2385
  account.command("create").description("Create a new smart account").requiredOption("-c, --chain <chainId>", "Target chain ID").option("-a, --alias <alias>", "Human-readable alias (default: random)").action(async (opts) => {
2260
- if (!ctx.deviceKey) {
2386
+ if (!ctx.keyring.isUnlocked) {
2261
2387
  error("Wallet not initialized. Run `elytro init` first.");
2262
2388
  process.exitCode = 1;
2263
2389
  return;
@@ -2306,7 +2432,7 @@ function registerAccountCommand(program2, ctx) {
2306
2432
  }
2307
2433
  });
2308
2434
  account.command("activate").description("Deploy the smart contract on-chain").argument("[account]", "Alias or address (default: current)").option("--no-sponsor", "Skip sponsorship check (user pays gas)").action(async (target, opts) => {
2309
- if (!ctx.deviceKey) {
2435
+ if (!ctx.keyring.isUnlocked) {
2310
2436
  error("Wallet not initialized. Run `elytro init` first.");
2311
2437
  process.exitCode = 1;
2312
2438
  return;
@@ -2704,7 +2830,7 @@ function registerTxCommand(program2, ctx) {
2704
2830
  }
2705
2831
  });
2706
2832
  tx.command("send").description("Send a transaction on-chain").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").option("--no-hook", "Skip SecurityHook signing (bypass 2FA)").option("--userop <json>", "Send a pre-built UserOp JSON (skips build step)").action(async (target, opts) => {
2707
- if (!ctx.deviceKey) {
2833
+ if (!ctx.keyring.isUnlocked) {
2708
2834
  handleTxError(new TxError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first."));
2709
2835
  return;
2710
2836
  }
@@ -2902,7 +3028,7 @@ function registerTxCommand(program2, ctx) {
2902
3028
  }
2903
3029
  });
2904
3030
  tx.command("simulate").description("Preview a transaction (gas estimate, sponsor check)").argument("[account]", "Source account alias or address (default: current)").option("--tx <spec...>", 'Transaction spec: "to:0xAddr,value:0.1,data:0x..." (repeatable, ordered)').option("--no-sponsor", "Skip sponsorship check").action(async (target, opts) => {
2905
- if (!ctx.deviceKey) {
3031
+ if (!ctx.keyring.isUnlocked) {
2906
3032
  handleTxError(new TxError(ERR_ACCOUNT_NOT_READY, "Wallet not initialized. Run `elytro init` first."));
2907
3033
  return;
2908
3034
  }
@@ -3500,7 +3626,7 @@ function handleSecurityError(err) {
3500
3626
  process.exitCode = 1;
3501
3627
  }
3502
3628
  function initSecurityContext(ctx) {
3503
- if (!ctx.deviceKey) {
3629
+ if (!ctx.keyring.isUnlocked) {
3504
3630
  throw new SecurityError(ERR_ACCOUNT_NOT_READY2, "Wallet not initialized. Run `elytro init` first.");
3505
3631
  }
3506
3632
  const current = ctx.account.currentAccount;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@elytro/cli",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "description": "Elytro ERC-4337 Smart Account Wallet CLI",
5
5
  "type": "module",
6
6
  "bin": {