@palmyr/cli 1.8.1 → 1.8.2

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.
package/README.md CHANGED
@@ -160,6 +160,23 @@ palmyr wallet import --mnemonic "twelve word seed phrase ..." --name imported
160
160
  palmyr wallet import --mnemonic "..." --name from-backup --tag restored --solana
161
161
  ```
162
162
 
163
+ ### Durable recovery — passphrase fallback
164
+
165
+ By default a wallet is **session-only**: the mnemonic is encrypted with a random session secret stored in your OS credential store (DPAPI / Keychain / `secret-tool`). That secret does not survive a different OS user, a fresh install, or a headless box without an unlocked keyring. Two ways to add a durable scrypt-derived passphrase that decrypts on any machine:
166
+
167
+ ```bash
168
+ # At create time — env var preferred (keeps the phrase out of shell history)
169
+ PALMYR_WALLET_PASSPHRASE="your-passphrase" palmyr wallet create --name agent-prod
170
+
171
+ # Migrate an existing wallet — run on the original machine while the OS session secret still works
172
+ palmyr wallet rekey <WALLET_ID> --passphrase "your-passphrase"
173
+
174
+ # Recover on a new machine — copy ~/.palmyr/wallet/wallets/<id>.json over, then:
175
+ PALMYR_WALLET_PASSPHRASE="your-passphrase" palmyr wallet info <WALLET_ID>
176
+ ```
177
+
178
+ When set, the wallet file gets a second AES-256-GCM blob (`owner_crypto`, scrypt KDF on the passphrase + random salt). Decryption tries the OS session secret first, then falls back to `PALMYR_WALLET_PASSPHRASE` / `--passphrase`. Minimum length 8 chars. Re-running `wallet rekey` rotates — old passphrase stops working.
179
+
163
180
  ### Exporting a seed
164
181
 
165
182
  ```bash
@@ -409,8 +426,9 @@ All wallet operations except `addresses`, `api-key`, `config`, and `request-appr
409
426
 
410
427
  | Command | Network | Notes |
411
428
  |---|---|---|
412
- | `palmyr wallet create [--name N] [--managed] [--solana\|--base] [--tag T] [--count N] [--name-prefix P]` | local *(server only if `--managed`)* | New wallet. Stores session secret in OS credential store. `--count > 1` bulk-creates N unmanaged wallets under a required `--tag` (max 500/call, batched DPAPI seal on Windows). `--solana` / `--base` materializes only one chain. |
413
- | `palmyr wallet import --mnemonic "..." [--name N] [--managed] [--solana\|--base] [--tag T]` | local | Restore from BIP-39. Same chain / tag flags as `create`. |
429
+ | `palmyr wallet create [--name N] [--managed] [--solana\|--base] [--tag T] [--count N] [--name-prefix P] [--passphrase P]` | local *(server only if `--managed`)* | New wallet. Stores session secret in OS credential store. `--count > 1` bulk-creates N unmanaged wallets under a required `--tag` (max 500/call, batched DPAPI seal on Windows). `--solana` / `--base` materializes only one chain. `--passphrase` (or `PALMYR_WALLET_PASSPHRASE` env) also seals the mnemonic with scrypt so the wallet survives OS-keychain loss. |
430
+ | `palmyr wallet import --mnemonic "..." [--name N] [--managed] [--solana\|--base] [--tag T] [--passphrase P]` | local | Restore from BIP-39. Same chain / tag / passphrase flags as `create`. |
431
+ | `palmyr wallet rekey <ID> --passphrase P` | local | Add (or rotate) the scrypt passphrase fallback on an existing wallet. Wallet must be decryptable right now (session secret still works, or `--current-passphrase` provided). Run on the original machine, then `PALMYR_WALLET_PASSPHRASE` decrypts the wallet anywhere. |
414
432
  | `palmyr wallet list [--tag T]` | local | Lists wallets in the local vault. `--tag` filters to one folder. |
415
433
  | `palmyr wallet info <ID>` | local | Show one wallet (id, name, addresses, mode, tag). |
416
434
  | `palmyr wallet tags` | local | List all tags with wallet count, chains, and date range. |
@@ -779,7 +797,7 @@ Config is stored in `~/.palmyr/config.json`. Environment variables override file
779
797
  | `PALMYR_PAY_WALLET` | Force a specific wallet ID for x402 payment. |
780
798
  | `PALMYR_WALLET_PATH` | Override vault directory (default `~/.palmyr/wallet`). |
781
799
  | `PALMYR_KEYFILE` | Solana keyfile path (legacy single-key flow). |
782
- | `PALMYR_WALLET_PASSPHRASE` | Optional BIP-39 passphrase for legacy import/export. |
800
+ | `PALMYR_WALLET_PASSPHRASE` | Vault decryption passphrase. Used when the wallet was created with `--passphrase` (or env) or migrated via `wallet rekey`. Falls back to the OS-keychain session secret when unset. |
783
801
 
784
802
  ### Chain selection during payment
785
803
 
@@ -838,6 +856,7 @@ The CLI uses distinct exit codes so scripts and agents can branch on failure mod
838
856
  - **Keys never leave the machine.** `wallet create` and `wallet import` never transmit seed material to the server. Even managed wallets register only the wallet ID and the derived public addresses with the server.
839
857
  - **Encryption at rest.** The wallet file is AES-256-GCM with the session secret as the key. The session secret is generated on wallet creation and never stored on disk in plaintext.
840
858
  - **OS credential store.** Session secrets live in DPAPI (Windows), Keychain (macOS), or `secret-tool` (libsecret on Linux). If none is available the CLI errors rather than falling back to plaintext.
859
+ - **Optional passphrase fallback.** Create with `--passphrase` (or `PALMYR_WALLET_PASSPHRASE` env), or migrate with `wallet rekey`, to add a second AES-256-GCM blob keyed by scrypt(passphrase, random salt). Lets the wallet decrypt on a different machine / user / headless box where the OS keychain isn't reachable. Decryption tries the OS session secret first, then falls back to the env/flag passphrase.
841
860
  - **Bidirectional vault integrity.** On every load, the CLI checks that every account stored in the wallet file is still derivable from the seed, **and** that every account derivable from the seed is still in the file. Either direction failing returns exit code `7`.
842
861
  - **Pre-flight validation.** Endpoints that are easy to fail (bad pubkey for an inbox, malformed E.164 for SMS, unsupported destination country) are validated **before** the x402 paywall, so you don't pay for requests that were never going to succeed.
843
862
  - **No `--no-verify`.** Hooks, signatures, and webhooks are always verified.
package/dist/cli.js CHANGED
@@ -219,6 +219,7 @@ const WALLET_HELP = {
219
219
  { flag: '--tag <name>', desc: 'Folder-like grouping tag', hint: 'e.g. palmyr-demo — required with --count' },
220
220
  { flag: '--count <N>', desc: 'Bulk-create N wallets in one call (1-500)', hint: 'unmanaged only; requires --tag' },
221
221
  { flag: '--name-prefix <p>', desc: 'Bulk name prefix; suffixed `-001..-N`', hint: 'default: same as --tag' },
222
+ { flag: '--passphrase <p>', desc: 'Also seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred — keeps phrase out of shell history)' },
222
223
  ],
223
224
  import: [
224
225
  { flag: '--mnemonic <words>', desc: 'BIP-39 mnemonic phrase (required)' },
@@ -227,6 +228,13 @@ const WALLET_HELP = {
227
228
  { flag: '--solana', desc: 'Materialize the Solana account only' },
228
229
  { flag: '--base', desc: 'Materialize the Base/EVM account only' },
229
230
  { flag: '--tag <name>', desc: 'Assign a tag at import time' },
231
+ { flag: '--passphrase <p>', desc: 'Also seal the mnemonic with this passphrase (≥8 chars) for durable recovery', hint: 'or PALMYR_WALLET_PASSPHRASE env (env preferred)' },
232
+ ],
233
+ rekey: [
234
+ { flag: '<WALLET_ID>', desc: 'Wallet ID or name (positional or --id)' },
235
+ { flag: '--passphrase <p>', desc: 'New passphrase to seal the mnemonic with (≥8 chars)', hint: 'or PALMYR_WALLET_PASSPHRASE env; interactive prompt if neither set' },
236
+ { flag: '--current-passphrase <p>', desc: 'Existing passphrase, if the wallet was already rekeyed and the OS session secret is gone', hint: 'or PALMYR_WALLET_PASSPHRASE_CURRENT env' },
237
+ { flag: '(note)', desc: 'Run on the original machine while the OS session secret is still resolvable. Becomes the durable recovery path on any other host.' },
230
238
  ],
231
239
  tags: [
232
240
  { flag: '(no args)', desc: 'List all tags with wallet count, chains, and date range' },
@@ -2153,6 +2161,7 @@ async function main() {
2153
2161
  { name: 'tag-delete', description: 'Cascade-delete every wallet under a tag', hint: 'TAG --confirm' },
2154
2162
  { name: 'sign-message', description: 'Sign a message', hint: 'WALLET_ID --chain evm --msg "hello"' },
2155
2163
  { name: 'export', description: 'Export mnemonic for backup', hint: 'WALLET_ID --confirm' },
2164
+ { name: 'rekey', description: 'Add or rotate the passphrase fallback (durable across OS-keychain loss)', hint: 'WALLET_ID --passphrase <p>' },
2156
2165
  { name: 'api-key', description: 'Create agent API key', hint: 'WALLET_ID --name my-agent' },
2157
2166
  { name: 'config', description: 'Get agent config', hint: 'WALLET_ID' },
2158
2167
  { name: 'use', description: 'Set default pay wallet', hint: 'WALLET_ID' },
@@ -2201,6 +2210,10 @@ async function main() {
2201
2210
  const chains = (wantSol && !wantBase) ? ['solana']
2202
2211
  : (wantBase && !wantSol) ? ['base']
2203
2212
  : ['solana', 'base'];
2213
+ // Optional passphrase fallback — seals the mnemonic with scrypt so
2214
+ // PALMYR_WALLET_PASSPHRASE can decrypt on a different machine / user /
2215
+ // headless box where the OS credential-store secret isn't reachable.
2216
+ const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2204
2217
  // ─── Bulk path ───
2205
2218
  if (count > 1) {
2206
2219
  if (isManaged)
@@ -2214,12 +2227,12 @@ async function main() {
2214
2227
  const { storeSecretsBatch } = await import('./credential-store.js');
2215
2228
  // Progress to stderr so JSON on stdout stays clean
2216
2229
  if (!AGENT_MODE)
2217
- process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"...\n`);
2218
- const results = createLocalWalletsBatch(prefix, count, 'unmanaged', { tag: tagRaw, chains });
2230
+ process.stderr.write(`creating ${count} wallets under tag "${tagRaw}"${passphrase ? ' (+ passphrase fallback)' : ''}...\n`);
2231
+ const results = createLocalWalletsBatch(prefix, count, 'unmanaged', { tag: tagRaw, chains, passphrase });
2219
2232
  if (!AGENT_MODE)
2220
2233
  process.stderr.write(`sealing ${count} session secrets in OS credential store...\n`);
2221
2234
  storeSecretsBatch(results.map(r => ({ account: r.id, secret: r.sessionSecret })));
2222
- log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')})`);
2235
+ log(`wallet create: ${count} wallets under tag "${tagRaw}" (chains=${chains.join(',')}${passphrase ? ', passphrase=set' : ''})`);
2223
2236
  if (AGENT_MODE) {
2224
2237
  print({
2225
2238
  count: results.length,
@@ -2251,7 +2264,7 @@ async function main() {
2251
2264
  const mode = isManaged ? 'managed' : 'unmanaged';
2252
2265
  // Create locally — no server needed for the key material
2253
2266
  const { createLocalWallet } = await import('./vault.js');
2254
- const w = createLocalWallet(name, mode, { tag: tagRaw, chains });
2267
+ const w = createLocalWallet(name, mode, { tag: tagRaw, chains, passphrase });
2255
2268
  // Store session secret in OS credential store
2256
2269
  const { storeSecret } = await import('./credential-store.js');
2257
2270
  storeSecret(w.id, w.sessionSecret);
@@ -2315,12 +2328,13 @@ async function main() {
2315
2328
  const chains = (wantSol && !wantBase) ? ['solana']
2316
2329
  : (wantBase && !wantSol) ? ['base']
2317
2330
  : ['solana', 'base'];
2331
+ const passphrase = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2318
2332
  const { importLocalWallet } = await import('./vault.js');
2319
- const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains });
2333
+ const w = importLocalWallet(name, mnemonic, mode, { tag: tagRaw, chains, passphrase });
2320
2334
  // Store session secret
2321
2335
  const { storeSecret } = await import('./credential-store.js');
2322
2336
  storeSecret(w.id, w.sessionSecret);
2323
- log(`wallet import: ${w.id}`);
2337
+ log(`wallet import: ${w.id}${passphrase ? ' (+ passphrase fallback)' : ''}`);
2324
2338
  if (!AGENT_MODE) {
2325
2339
  render(React.createElement(WalletCreateScreen, {
2326
2340
  version: VERSION,
@@ -2629,6 +2643,43 @@ async function main() {
2629
2643
  }
2630
2644
  break;
2631
2645
  }
2646
+ case 'rekey': {
2647
+ const walletId = positional[0] || flags.id;
2648
+ if (!walletId)
2649
+ err('Wallet ID required: palmyr wallet rekey <WALLET_ID> --passphrase <p>', EXIT.BAD_INPUT);
2650
+ // New passphrase: flag → env → interactive prompt (TTY only).
2651
+ let newPass = flags.passphrase || process.env.PALMYR_WALLET_PASSPHRASE || undefined;
2652
+ if (!newPass) {
2653
+ if (!process.stdin.isTTY)
2654
+ err('--passphrase or PALMYR_WALLET_PASSPHRASE required (no TTY for interactive prompt)', EXIT.BAD_INPUT);
2655
+ const { promptNewPassphrase } = await import('./passphrase-prompt.js');
2656
+ newPass = await promptNewPassphrase();
2657
+ }
2658
+ // Current passphrase is only needed if the wallet was already
2659
+ // passphrase-sealed and the OS session secret is gone (rare —
2660
+ // typically the rekey runs on the original machine where the
2661
+ // session secret still resolves).
2662
+ const currentPass = flags['current-passphrase'] || process.env.PALMYR_WALLET_PASSPHRASE_CURRENT || undefined;
2663
+ const { rekeyWallet } = await import('./vault.js');
2664
+ let result;
2665
+ try {
2666
+ result = rekeyWallet(walletId, newPass, currentPass);
2667
+ }
2668
+ catch (e) {
2669
+ const code = e.message?.includes('SECURITY') ? EXIT.SECURITY : EXIT.GENERAL;
2670
+ err(e.message, code);
2671
+ }
2672
+ log(`wallet rekey: ${result.id} (${result.rotated ? 'rotated' : 'added'})`);
2673
+ if (!AGENT_MODE) {
2674
+ const verb = result.rotated ? 'Rotated' : 'Added';
2675
+ console.log(`\n ${t.success}✔${t.reset} ${verb} passphrase fallback on ${t.accent}${result.name}${t.reset}`);
2676
+ console.log(` ${t.muted}Wallet now decrypts with PALMYR_WALLET_PASSPHRASE on any machine (in addition to the OS keychain on this one).${t.reset}\n`);
2677
+ }
2678
+ else {
2679
+ print({ success: true, ...result });
2680
+ }
2681
+ break;
2682
+ }
2632
2683
  case 'buy': {
2633
2684
  const chain = (positional[0] || 'solana').toLowerCase();
2634
2685
  if (chain !== 'solana' && chain !== 'base')