Skip to content

Commit cf4b46b

Browse files
committed
feat: add GPG seed phrase backup to CLI and wallet SDK
CLI: - coinpay wallet backup-seed: encrypt seed phrase to .gpg file using system gpg - coinpay wallet decrypt-backup: decrypt a .gpg backup file - Supports --seed, --password, --wallet-id, --output flags - Interactive password prompt with confirmation when TTY - Stdin pipe support for seed phrase input - Secure temp file cleanup (overwrite before delete) - Fixed --flag value parsing (now supports space-separated, not just =) SDK (wallet-sdk): - New backup.ts: encryptSeedPhrase() and decryptSeedPhrase() using openpgp - Wallet.exportEncryptedBackup(password): instance method for backup - Wallet.decryptBackup(data, password): static method for restore - Exported from wallet-sdk index with EncryptedBackup type All encryption is GPG-compatible (AES-256, symmetric). Files produced by CLI, SDK, and web UI are all interoperable with each other and standard gpg.
1 parent 00e751d commit cf4b46b

File tree

4 files changed

+414
-2
lines changed

4 files changed

+414
-2
lines changed

packages/sdk/bin/coinpay.js

Lines changed: 269 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,11 @@
88
import { CoinPayClient } from '../src/client.js';
99
import { PaymentStatus, Blockchain, FiatCurrency } from '../src/payments.js';
1010
import { readFileSync, writeFileSync, existsSync } from 'fs';
11+
import { execSync } from 'child_process';
12+
import { createInterface } from 'readline';
1113
import { homedir } from 'os';
1214
import { join } from 'path';
15+
import { tmpdir } from 'os';
1316

1417
const VERSION = '0.3.3';
1518
const CONFIG_FILE = join(homedir(), '.coinpay.json');
@@ -95,8 +98,19 @@ function parseArgs(args) {
9598
const arg = args[i];
9699

97100
if (arg.startsWith('--')) {
98-
const [key, value] = arg.slice(2).split('=');
99-
result.flags[key] = value ?? true;
101+
const eqIdx = arg.indexOf('=');
102+
if (eqIdx !== -1) {
103+
result.flags[arg.slice(2, eqIdx)] = arg.slice(eqIdx + 1);
104+
} else {
105+
// Peek ahead: if next arg doesn't start with '-', it's the value
106+
const next = args[i + 1];
107+
if (next && !next.startsWith('-')) {
108+
result.flags[arg.slice(2)] = next;
109+
i++;
110+
} else {
111+
result.flags[arg.slice(2)] = true;
112+
}
113+
}
100114
} else if (arg.startsWith('-')) {
101115
result.flags[arg.slice(1)] = args[++i] ?? true;
102116
} else if (!result.command) {
@@ -143,6 +157,10 @@ ${colors.cyan}Commands:${colors.reset}
143157
get <crypto> Get exchange rate
144158
list Get all exchange rates
145159
160+
${colors.bright}wallet${colors.reset}
161+
backup-seed Encrypt seed phrase to GPG file
162+
decrypt-backup <file> Decrypt a GPG backup file
163+
146164
${colors.bright}webhook${colors.reset}
147165
logs <business-id> Get webhook logs
148166
test <business-id> Send test webhook
@@ -156,6 +174,10 @@ ${colors.cyan}Options:${colors.reset}
156174
--currency <code> Fiat currency (USD, EUR, etc.) - default: USD
157175
--blockchain <code> Blockchain (BTC, ETH, SOL, POL, BCH, USDC_ETH, USDC_POL, USDC_SOL)
158176
--description <text> Payment description
177+
--seed <phrase> Seed phrase (or reads from stdin)
178+
--password <pass> GPG passphrase (or prompts interactively)
179+
--wallet-id <id> Wallet ID for backup filename
180+
--output <path> Output file path (default: wallet_<id>_seedphrase.txt.gpg)
159181
160182
${colors.cyan}Examples:${colors.reset}
161183
# Configure your API key (get it from your CoinPay dashboard)
@@ -179,6 +201,18 @@ ${colors.cyan}Examples:${colors.reset}
179201
# List your businesses
180202
coinpay business list
181203
204+
# Encrypt seed phrase to GPG backup file
205+
coinpay wallet backup-seed --seed "word1 word2 ..." --password "mypass" --wallet-id "wid-abc"
206+
207+
# Encrypt seed phrase (interactive password prompt)
208+
coinpay wallet backup-seed --seed "word1 word2 ..." --wallet-id "wid-abc"
209+
210+
# Pipe seed phrase from stdin
211+
echo "word1 word2 ..." | coinpay wallet backup-seed --wallet-id "wid-abc" --password "mypass"
212+
213+
# Decrypt a backup file
214+
coinpay wallet decrypt-backup wallet_wid-abc_seedphrase.txt.gpg --password "mypass"
215+
182216
${colors.cyan}Environment Variables:${colors.reset}
183217
COINPAY_API_KEY API key (overrides config)
184218
COINPAY_BASE_URL Custom API URL
@@ -453,6 +487,235 @@ async function handleWebhook(subcommand, args, flags) {
453487
}
454488
}
455489

490+
/**
491+
* Prompt for password interactively (hides input)
492+
*/
493+
function promptPassword(prompt = 'Password: ') {
494+
return new Promise((resolve) => {
495+
process.stdout.write(prompt);
496+
497+
// If stdin is a TTY, read with hidden input
498+
if (process.stdin.isTTY) {
499+
const rl = createInterface({ input: process.stdin, output: process.stdout });
500+
// Temporarily override output to hide password chars
501+
const origWrite = process.stdout.write.bind(process.stdout);
502+
process.stdout.write = (chunk) => {
503+
// Only suppress characters that are the user's input
504+
if (typeof chunk === 'string' && chunk !== prompt && chunk !== '\n' && chunk !== '\r\n') {
505+
return true;
506+
}
507+
return origWrite(chunk);
508+
};
509+
510+
rl.question('', (answer) => {
511+
process.stdout.write = origWrite;
512+
process.stdout.write('\n');
513+
rl.close();
514+
resolve(answer);
515+
});
516+
} else {
517+
// Pipe mode — read from stdin
518+
const chunks = [];
519+
process.stdin.on('data', (chunk) => chunks.push(chunk));
520+
process.stdin.on('end', () => resolve(Buffer.concat(chunks).toString().trim()));
521+
process.stdin.resume();
522+
}
523+
});
524+
}
525+
526+
/**
527+
* Check if gpg is available
528+
*/
529+
function hasGpg() {
530+
try {
531+
execSync('gpg --version', { stdio: 'pipe' });
532+
return true;
533+
} catch {
534+
return false;
535+
}
536+
}
537+
538+
/**
539+
* Wallet commands
540+
*/
541+
async function handleWallet(subcommand, args, flags) {
542+
switch (subcommand) {
543+
case 'backup-seed': {
544+
if (!hasGpg()) {
545+
print.error('gpg is required but not found. Install it with:');
546+
print.info(' Ubuntu/Debian: sudo apt install gnupg');
547+
print.info(' macOS: brew install gnupg');
548+
print.info(' Windows: https://www.gnupg.org/download/');
549+
process.exit(1);
550+
}
551+
552+
const walletId = flags['wallet-id'];
553+
if (!walletId) {
554+
print.error('Required: --wallet-id <id>');
555+
return;
556+
}
557+
558+
// Get seed phrase from --seed flag or stdin
559+
let seed = flags.seed;
560+
if (!seed) {
561+
if (process.stdin.isTTY) {
562+
print.error('Required: --seed <phrase> (or pipe via stdin)');
563+
print.info('Example: coinpay wallet backup-seed --seed "word1 word2 ..." --wallet-id "wid-abc"');
564+
return;
565+
}
566+
// Read from stdin
567+
const chunks = [];
568+
for await (const chunk of process.stdin) {
569+
chunks.push(chunk);
570+
}
571+
seed = Buffer.concat(chunks).toString().trim();
572+
}
573+
574+
if (!seed) {
575+
print.error('Seed phrase is empty');
576+
return;
577+
}
578+
579+
// Get password from --password flag or prompt
580+
let password = flags.password;
581+
if (!password) {
582+
if (!process.stdin.isTTY) {
583+
print.error('Required: --password <pass> (cannot prompt in pipe mode)');
584+
return;
585+
}
586+
password = await promptPassword('Encryption password: ');
587+
const confirm = await promptPassword('Confirm password: ');
588+
if (password !== confirm) {
589+
print.error('Passwords do not match');
590+
return;
591+
}
592+
}
593+
594+
if (!password) {
595+
print.error('Password is empty');
596+
return;
597+
}
598+
599+
// Build the plaintext content
600+
const filename = `wallet_${walletId}_seedphrase.txt`;
601+
const content = [
602+
'# CoinPayPortal Wallet Seed Phrase Backup',
603+
`# Wallet ID: ${walletId}`,
604+
`# Created: ${new Date().toISOString()}`,
605+
'#',
606+
'# KEEP THIS FILE SAFE. Anyone with this phrase can access your funds.',
607+
`# Decrypt with: gpg --decrypt ${filename}.gpg`,
608+
'',
609+
seed,
610+
'',
611+
].join('\n');
612+
613+
// Determine output path
614+
const outputPath = flags.output || `${filename}.gpg`;
615+
616+
// Write plaintext to temp file, encrypt with gpg, remove temp
617+
const tmpFile = join(tmpdir(), `coinpay-backup-${Date.now()}.txt`);
618+
try {
619+
writeFileSync(tmpFile, content, { mode: 0o600 });
620+
621+
// Write passphrase to a temp file for gpg (avoids shell escaping issues)
622+
const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
623+
writeFileSync(passFile, password, { mode: 0o600 });
624+
try {
625+
execSync(
626+
`gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --symmetric --cipher-algo AES256 --output "${outputPath}" "${tmpFile}"`,
627+
{ stdio: ['pipe', 'pipe', 'pipe'] }
628+
);
629+
} finally {
630+
try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); } catch {}
631+
try { const { unlinkSync: u } = await import('fs'); u(passFile); } catch {}
632+
}
633+
634+
print.success(`Encrypted backup saved to: ${outputPath}`);
635+
print.info(`Decrypt with: gpg --decrypt ${outputPath}`);
636+
} finally {
637+
// Securely delete temp file
638+
try {
639+
writeFileSync(tmpFile, Buffer.alloc(content.length, 0));
640+
const { unlinkSync } = await import('fs');
641+
unlinkSync(tmpFile);
642+
} catch {
643+
// Best effort cleanup
644+
}
645+
}
646+
break;
647+
}
648+
649+
case 'decrypt-backup': {
650+
if (!hasGpg()) {
651+
print.error('gpg is required but not found.');
652+
process.exit(1);
653+
}
654+
655+
const filePath = args[0];
656+
if (!filePath) {
657+
print.error('Backup file path required');
658+
print.info('Example: coinpay wallet decrypt-backup wallet_wid-abc_seedphrase.txt.gpg');
659+
return;
660+
}
661+
662+
if (!existsSync(filePath)) {
663+
print.error(`File not found: ${filePath}`);
664+
return;
665+
}
666+
667+
// Get password
668+
let password = flags.password;
669+
if (!password) {
670+
if (!process.stdin.isTTY) {
671+
print.error('Required: --password <pass> (cannot prompt in pipe mode)');
672+
return;
673+
}
674+
password = await promptPassword('Decryption password: ');
675+
}
676+
677+
try {
678+
const passFile = join(tmpdir(), `coinpay-pass-${Date.now()}`);
679+
writeFileSync(passFile, password, { mode: 0o600 });
680+
let result;
681+
try {
682+
result = execSync(
683+
`gpg --batch --yes --passphrase-file "${passFile}" --pinentry-mode loopback --decrypt "${filePath}"`,
684+
{ stdio: ['pipe', 'pipe', 'pipe'] }
685+
);
686+
} finally {
687+
try { writeFileSync(passFile, Buffer.alloc(password.length, 0)); } catch {}
688+
try { const { unlinkSync: u } = await import('fs'); u(passFile); } catch {}
689+
}
690+
691+
const output = result.toString();
692+
// Extract just the mnemonic (skip comments)
693+
const lines = output.split('\n');
694+
const mnemonic = lines
695+
.filter((l) => !l.startsWith('#') && l.trim().length > 0)
696+
.join(' ')
697+
.trim();
698+
699+
if (flags.json) {
700+
print.json({ mnemonic, raw: output });
701+
} else {
702+
print.success('Backup decrypted successfully');
703+
console.log(`\n${colors.bright}Seed Phrase:${colors.reset}`);
704+
console.log(`${colors.yellow}${mnemonic}${colors.reset}\n`);
705+
print.warn('This is sensitive data — do not share it with anyone.');
706+
}
707+
} catch (err) {
708+
print.error('Decryption failed — wrong password or corrupted file');
709+
}
710+
break;
711+
}
712+
713+
default:
714+
print.error(`Unknown wallet command: ${subcommand}`);
715+
print.info('Available: backup-seed, decrypt-backup');
716+
}
717+
}
718+
456719
/**
457720
* Main entry point
458721
*/
@@ -491,6 +754,10 @@ async function main() {
491754
case 'webhook':
492755
await handleWebhook(subcommand, args, flags);
493756
break;
757+
758+
case 'wallet':
759+
await handleWallet(subcommand, args, flags);
760+
break;
494761

495762
default:
496763
print.error(`Unknown command: ${command}`);

0 commit comments

Comments
 (0)