Skip to content

Commit 5bb9951

Browse files
graysonhycclaude
andcommitted
feat(wallet): add wallet export-key to export raw private keys
OWS only exposes mnemonics; users who need a raw EVM (0x hex) or Solana (base58 keypair) private key had to derive externally. This adds a CLI command that derives keys client-side from the wallet's mnemonic using BIP-44/secp256k1 (EVM, m/44'/60'/0'/0/N) and SLIP-0010/ed25519 (Solana, m/44'/501'/N'/0' — Phantom convention). Same safety posture as `wallet backup`: passphrase prompt is interactive-only, output goes to stderr (never stdout), warning banner above and below. Flags: --wallet, --chain evm|solana|all (default all), --index N (default 0). Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 257ebe0 commit 5bb9951

4 files changed

Lines changed: 183 additions & 0 deletions

File tree

cli/commands/wallet/export-key.js

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
import * as ows from "../../utils/wallet/keystore.js";
2+
import { printError } from "../../utils/common/output.js";
3+
import { getConfigValue } from "../../utils/config.js";
4+
import { readPassphrase } from "../../utils/common/prompt.js";
5+
import { deriveEvmKey, deriveSolanaKey } from "../../utils/wallet/derive-keys.js";
6+
7+
/**
8+
* zerion wallet export-key --wallet <name> [--chain evm|solana|all] [--index N]
9+
*
10+
* Derives raw private keys from the wallet's mnemonic and writes them to
11+
* stderr (never stdout — same safety stance as `wallet backup`). The OWS
12+
* vault does not expose private keys directly; we derive them from the
13+
* mnemonic returned by exportWallet.
14+
*/
15+
export default async function walletExportKey(args, flags) {
16+
const walletName = flags.wallet || args[0] || getConfigValue("defaultWallet");
17+
18+
if (!walletName) {
19+
printError("no_wallet", "No wallet specified", {
20+
suggestion: "Use --wallet <name> or set default: zerion config set defaultWallet <name>",
21+
});
22+
process.exit(1);
23+
}
24+
25+
const chain = (flags.chain || "all").toLowerCase();
26+
if (!["evm", "solana", "all"].includes(chain)) {
27+
printError("bad_flag", `Invalid --chain: ${chain}`, {
28+
suggestion: "Use --chain evm | solana | all",
29+
});
30+
process.exit(1);
31+
}
32+
33+
const index = flags.index !== undefined ? Number(flags.index) : 0;
34+
if (!Number.isInteger(index) || index < 0) {
35+
printError("bad_flag", `Invalid --index: ${flags.index}`, {
36+
suggestion: "Use a non-negative integer (default 0)",
37+
});
38+
process.exit(1);
39+
}
40+
41+
process.stderr.write(
42+
"\n⚠️ WARNING: This will display raw private key(s) derived from your mnemonic.\n" +
43+
" Anyone with a private key can drain that account on the matching chain.\n" +
44+
" Never share. Never paste into a website. Prefer `wallet backup` (mnemonic).\n\n"
45+
);
46+
47+
try {
48+
const passphrase = await readPassphrase();
49+
const mnemonic = ows.exportWallet(walletName, passphrase);
50+
const wallet = ows.getWallet(walletName);
51+
52+
process.stderr.write(`\n Wallet: ${wallet.name}\n`);
53+
process.stderr.write(` Path idx: ${index}\n\n`);
54+
55+
if (chain === "evm" || chain === "all") {
56+
const evm = deriveEvmKey(mnemonic, index);
57+
process.stderr.write(` EVM\n`);
58+
process.stderr.write(` address: ${wallet.evmAddress}\n`);
59+
process.stderr.write(` path: ${evm.path}\n`);
60+
process.stderr.write(` private key: ${evm.privateKey}\n\n`);
61+
}
62+
63+
if (chain === "solana" || chain === "all") {
64+
const sol = deriveSolanaKey(mnemonic, index);
65+
process.stderr.write(` Solana\n`);
66+
process.stderr.write(` address: ${sol.address}\n`);
67+
process.stderr.write(` path: ${sol.path}\n`);
68+
process.stderr.write(` secret key: ${sol.privateKeyBase58}\n`);
69+
process.stderr.write(` (base58, 64-byte Phantom keypair format)\n`);
70+
process.stderr.write(` seed (hex): ${sol.privateKeyHex}\n`);
71+
process.stderr.write(` (32-byte ed25519 seed)\n\n`);
72+
}
73+
74+
process.stderr.write(" ⚠️ Treat each line above as fully sensitive. Wipe scrollback.\n\n");
75+
} catch (err) {
76+
printError("ows_error", `Failed to export private key: ${err.message}`, {
77+
suggestion: "Check wallet name and passphrase. List wallets: zerion wallet list",
78+
});
79+
process.exit(1);
80+
}
81+
}

cli/router.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ function printUsage() {
2828
"wallet list": "List all wallets",
2929
"wallet fund": "Show deposit addresses for funding",
3030
"wallet backup --wallet <name>": "Export recovery phrase (mnemonic backup)",
31+
"wallet export-key --wallet <name>": "Export raw private key(s) derived from mnemonic (--chain evm|solana|all, --index N)",
3132
"wallet delete <name>": "Permanently delete a wallet (requires passphrase)",
3233
"wallet sync --wallet <name>": "Sync wallet to Zerion app via QR code",
3334
"wallet sync --all": "Sync all wallets to Zerion app",

cli/utils/wallet/derive-keys.js

Lines changed: 99 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,99 @@
1+
/**
2+
* Derive raw private keys from a BIP-39 mnemonic.
3+
*
4+
* OWS (@open-wallet-standard/core) does not expose private keys — it returns
5+
* mnemonics from exportWallet. Callers that need raw keys derive them here.
6+
*
7+
* EVM: BIP-44 secp256k1, path m/44'/60'/0'/0/<index>
8+
* Solana: SLIP-0010 ed25519, path m/44'/501'/<index>'/0' (Phantom convention)
9+
*/
10+
11+
import { Buffer } from "node:buffer";
12+
import { mnemonicToSeedSync, validateMnemonic } from "@scure/bip39";
13+
import { wordlist } from "@scure/bip39/wordlists/english";
14+
import { HDKey } from "@scure/bip32";
15+
import { hmac } from "@noble/hashes/hmac.js";
16+
import { sha512 } from "@noble/hashes/sha512.js";
17+
import { Keypair } from "@solana/web3.js";
18+
19+
const BASE58_ALPHABET = "123456789ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnopqrstuvwxyz";
20+
21+
function toHex(bytes) {
22+
return Buffer.from(bytes).toString("hex");
23+
}
24+
25+
function bytesToBase58(bytes) {
26+
let num = 0n;
27+
for (const b of bytes) num = num * 256n + BigInt(b);
28+
let out = "";
29+
while (num > 0n) {
30+
const rem = Number(num % 58n);
31+
out = BASE58_ALPHABET[rem] + out;
32+
num = num / 58n;
33+
}
34+
for (const b of bytes) {
35+
if (b === 0) out = "1" + out;
36+
else break;
37+
}
38+
return out;
39+
}
40+
41+
/**
42+
* SLIP-0010 ed25519 derivation. Only hardened steps are valid.
43+
* @param {Uint8Array} seed - BIP-39 seed (64 bytes)
44+
* @param {number[]} pathIndices - hardened indices already OR'd with 0x80000000
45+
* @returns {Uint8Array} 32-byte private seed (input to Keypair.fromSeed)
46+
*/
47+
function deriveEd25519(seed, pathIndices) {
48+
const masterKey = new TextEncoder().encode("ed25519 seed");
49+
let I = hmac(sha512, masterKey, seed);
50+
let key = I.slice(0, 32);
51+
let chain = I.slice(32);
52+
53+
for (const idx of pathIndices) {
54+
const data = new Uint8Array(1 + 32 + 4);
55+
data[0] = 0x00;
56+
data.set(key, 1);
57+
data[33] = (idx >>> 24) & 0xff;
58+
data[34] = (idx >>> 16) & 0xff;
59+
data[35] = (idx >>> 8) & 0xff;
60+
data[36] = idx & 0xff;
61+
I = hmac(sha512, chain, data);
62+
key = I.slice(0, 32);
63+
chain = I.slice(32);
64+
}
65+
return key;
66+
}
67+
68+
const HARDENED = 0x80000000;
69+
70+
export function deriveEvmKey(mnemonic, index = 0) {
71+
if (!validateMnemonic(mnemonic, wordlist)) {
72+
throw new Error("Invalid mnemonic");
73+
}
74+
const seed = mnemonicToSeedSync(mnemonic);
75+
const node = HDKey.fromMasterSeed(seed).derive(`m/44'/60'/0'/0/${index}`);
76+
if (!node.privateKey) throw new Error("EVM derivation produced no private key");
77+
return {
78+
privateKey: "0x" + toHex(node.privateKey),
79+
address: null, // viem could derive; left null to avoid extra dep at this layer
80+
path: `m/44'/60'/0'/0/${index}`,
81+
};
82+
}
83+
84+
export function deriveSolanaKey(mnemonic, index = 0) {
85+
if (!validateMnemonic(mnemonic, wordlist)) {
86+
throw new Error("Invalid mnemonic");
87+
}
88+
const seed = mnemonicToSeedSync(mnemonic);
89+
const path = [44 | HARDENED, 501 | HARDENED, index | HARDENED, 0 | HARDENED];
90+
const privSeed = deriveEd25519(seed, path);
91+
const kp = Keypair.fromSeed(privSeed);
92+
const secret64 = kp.secretKey; // 32 priv + 32 pub
93+
return {
94+
privateKeyBase58: bytesToBase58(secret64),
95+
privateKeyHex: toHex(secret64.slice(0, 32)),
96+
address: kp.publicKey.toBase58(),
97+
path: `m/44'/501'/${index}'/0'`,
98+
};
99+
}

cli/zerion.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ import walletImport from "./commands/wallet/import.js";
2424
import walletList from "./commands/wallet/list.js";
2525
import walletFund from "./commands/wallet/fund.js";
2626
import walletBackup from "./commands/wallet/backup.js";
27+
import walletExportKey from "./commands/wallet/export-key.js";
2728
import walletDelete from "./commands/wallet/delete.js";
2829
import walletSync from "./commands/wallet/sync.js";
2930
import walletSignMessage from "./commands/wallet/sign-message.js";
@@ -34,6 +35,7 @@ register("wallet", "import", walletImport);
3435
register("wallet", "list", walletList);
3536
register("wallet", "fund", walletFund);
3637
register("wallet", "backup", walletBackup);
38+
register("wallet", "export-key", walletExportKey);
3739
register("wallet", "delete", walletDelete);
3840
register("wallet", "sync", walletSync);
3941
registerSingle("sign-message", walletSignMessage);

0 commit comments

Comments
 (0)