#!/usr/bin/env -S deno run --allow-env --allow-read --allow-net --node-modules-dir=auto // --------------------------------------------------------------------------- // nsec-to-sp.ts (Deno port of nsec-to-sp.mjs) // // Convert a Nostr nsec into a BIP-32 xprv and a BIP-352 silent-payment address, // per NIP-SP §2: // // master = HMAC-SHA512("Bitcoin seed", nsec_bytes) // bspend = master / 352' / coin_type' / 0' / 0' / 0 // bscan = master / 352' / coin_type' / 0' / 1' / 0 // address = bech32m("sp" | "tsp", v0 ‖ Bscan ‖ Bspend) // // Use to test cross-wallet interop: import the printed xprv (or the // account-level xprv at m/352'/coin_type'/0') into any BIP-32-aware silent- // payment wallet and check it arrives at the same address. // // Run: // deno run scripts/nsec-to-sp.ts # interactive prompt // deno run scripts/nsec-to-sp.ts <nsec1...|hex> # non-interactive // deno run scripts/nsec-to-sp.ts --testnet // // Or executable (chmod +x first): // ./scripts/nsec-to-sp.ts // // Permissions needed (covered by the shebang): // --allow-env --allow-read --allow-net --node-modules-dir=auto // `--allow-net` is only used by Deno on first run to fetch the npm packages // from the registry; subsequent runs read from the local cache. The // `--node-modules-dir=auto` flag is required because the project root has // a Node-style `package.json` — without it Deno tries (and fails) to // resolve npm specifiers against the project's own `node_modules/` rather // than its own per-script cache. // --------------------------------------------------------------------------- import { HDKey } from 'npm:@scure/bip32@^1.4.0'; import { bech32, bech32m } from 'npm:@scure/base@^1.1.5'; const MAINNET_VERSIONS = { private: 0x0488ade4, public: 0x0488b21e }; // xprv / xpub const TESTNET_VERSIONS = { private: 0x04358394, public: 0x043587cf }; // tprv / tpub // --------------------------------------------------------------------------- // Encoding helpers // --------------------------------------------------------------------------- function bytesToHex(b: Uint8Array): string { return Array.from(b, (x) => x.toString(16).padStart(2, '0')).join(''); } function parseNsecArg(s: string): Uint8Array { const t = s.trim(); if (t.startsWith('nsec1')) { const { prefix, words } = bech32.decode(t.toLowerCase() as `${string}1${string}`, 1023); if (prefix !== 'nsec') throw new Error(`Expected nsec prefix, got "${prefix}"`); const data = bech32.fromWords(words); if (data.length !== 32) throw new Error(`nsec body must be 32 bytes, got ${data.length}`); return new Uint8Array(data); } if (/^[0-9a-f]{64}$/i.test(t)) { const out = new Uint8Array(32); for (let i = 0; i < 32; i++) out[i] = parseInt(t.slice(i * 2, i * 2 + 2), 16); return out; } throw new Error('Expected nsec1… (bech32) or 64-char hex private key'); } /** * BIP-352 address: bech32m("sp"|"tsp", v0 ‖ Bscan ‖ Bspend). */ function encodeSpAddress(Bscan: Uint8Array, Bspend: Uint8Array, network: string): string { const hrp = network === 'mainnet' ? 'sp' : 'tsp'; const payload = new Uint8Array(Bscan.length + Bspend.length); payload.set(Bscan, 0); payload.set(Bspend, Bscan.length); return bech32m.encode(hrp, [0, ...bech32m.toWords(payload)], 1023); } /** * Watch-only scan-key export — bscan_priv (32) ‖ Bspend_compressed (33), * bech32m HRP "spscan", version 0. 65-byte payload. */ function encodeSpScanKey(bscanPriv: Uint8Array, Bspend: Uint8Array): string { const payload = new Uint8Array(bscanPriv.length + Bspend.length); payload.set(bscanPriv, 0); payload.set(Bspend, bscanPriv.length); return bech32m.encode('spscan', [0, ...bech32m.toWords(payload)], 1023); } // --------------------------------------------------------------------------- // Interactive prompt with terminal echo disabled // --------------------------------------------------------------------------- const ENC = new TextEncoder(); async function writeErr(s: string): Promise<void> { await Deno.stderr.write(ENC.encode(s)); } /** * Read a line from stdin without echoing it back. Used so the nsec never * appears in terminal scrollback or shell history. Prompt text goes to * stderr so stdout (the key tables) stays pipe-clean. */ async function promptHidden(question: string): Promise<string> { if (!Deno.stdin.isTerminal()) { throw new Error('stdin is not a TTY — pass nsec as a command-line argument when piping/redirecting'); } await writeErr(question); Deno.stdin.setRaw(true); const buf = new Uint8Array(1024); let input = ''; try { outer: while (true) { const n = await Deno.stdin.read(buf); if (n === null) break; for (let i = 0; i < n; i++) { const code = buf[i]; if (code === 0x0d || code === 0x0a || code === 0x04) { // Enter or Ctrl-D break outer; } if (code === 0x03) { // Ctrl-C Deno.stdin.setRaw(false); await writeErr('\n'); Deno.exit(130); } if (code === 0x7f || code === 0x08) { // Backspace / DEL if (input.length > 0) input = input.slice(0, -1); continue; } input += String.fromCharCode(code); } } } finally { Deno.stdin.setRaw(false); await writeErr('\n'); } return input; } interface Options { key: string | null; network: 'mainnet' | 'testnet'; help: boolean; } /** * Resolve the seed bytes either from `opts.key` (non-interactive) or by * prompting. Retries up to 3 times on parse failure so a single typo doesn't * force a full re-run. */ async function resolveSeed(opts: Options): Promise<Uint8Array> { if (opts.key) return parseNsecArg(opts.key); await writeErr('Enter your nsec (bech32 "nsec1..." or 64-char hex).\n'); await writeErr('Input is hidden; nothing is written to disk or shell history.\n'); await writeErr('Ctrl-C to cancel.\n\n'); let lastErr: Error | null = null; for (let attempt = 0; attempt < 3; attempt++) { const input = await promptHidden('nsec: '); if (!input) { await writeErr(' (empty input)\n\n'); continue; } try { return parseNsecArg(input); } catch (e) { lastErr = e instanceof Error ? e : new Error(String(e)); await writeErr(` ${lastErr.message}\n\n`); } } throw new Error(`Too many invalid attempts${lastErr ? ` (last: ${lastErr.message})` : ''}`); } // --------------------------------------------------------------------------- // CLI // --------------------------------------------------------------------------- function parseArgs(argv: string[]): Options { let network: 'mainnet' | 'testnet' = 'mainnet'; let key: string | null = null; let help = false; for (let i = 0; i < argv.length; i++) { const a = argv[i]; if (a === '--mainnet') network = 'mainnet'; else if (a === '--testnet') network = 'testnet'; else if (a === '--network') { const v = argv[++i]; if (v !== 'mainnet' && v !== 'testnet') throw new Error(`Unknown network "${v}"`); network = v; } else if (a.startsWith('--network=')) { const v = a.slice('--network='.length); if (v !== 'mainnet' && v !== 'testnet') throw new Error(`Unknown network "${v}"`); network = v; } else if (a === '-h' || a === '--help') help = true; else if (!key) key = a; else throw new Error(`Unexpected positional arg: ${a}`); } return { key, network, help }; } function usage(): void { console.log(`Usage: deno run scripts/nsec-to-sp.ts [<nsec1...|hex>] [--network=mainnet|testnet] Derives the BIP-352 silent-payment keys from a Nostr nsec via BIP-32 hardened derivation (NIP-SP §2). Prints the BIP-32 master xprv, the account-level xprv at m/352'/<coin_type>'/0', and the silent-payment address. If no nsec is given as an argument, the script prompts for it interactively with terminal echo disabled — preferred for real keys so the secret doesn't land in shell history. Options: --network=mainnet (default) coin_type=0', HRP=sp --network=testnet coin_type=1', HRP=tsp --mainnet / --testnet shortcuts for the above Permissions: --allow-env --allow-read --allow-net (only needed on first run to fetch the npm packages; cached locally afterwards). `); } async function main(): Promise<void> { let opts: Options; try { opts = parseArgs(Deno.args); } catch (e) { console.error(`Error: ${e instanceof Error ? e.message : String(e)}`); usage(); Deno.exit(1); } if (opts.help) { usage(); Deno.exit(0); } const seed = await resolveSeed(opts); const versions = opts.network === 'mainnet' ? MAINNET_VERSIONS : TESTNET_VERSIONS; const master = HDKey.fromMasterSeed(seed, versions); const coinType = opts.network === 'mainnet' ? "0'" : "1'"; const accountPath = `m/352'/${coinType}/0'`; const scanPath = `${accountPath}/1'/0`; const spendPath = `${accountPath}/0'/0`; const accountNode = master.derive(accountPath); const scanNode = master.derive(scanPath); const spendNode = master.derive(spendPath); const Bscan = scanNode.publicKey; const Bspend = spendNode.publicKey; if (!Bscan || !Bspend) throw new Error('BIP-32 derivation produced no public key'); if (!scanNode.privateKey) throw new Error('Scan node has no private key (xpub-only master?)'); const address = encodeSpAddress(Bscan, Bspend, opts.network); const spScanKey = encodeSpScanKey(scanNode.privateKey, Bspend); // ------------------------------------------------------------------------- // Output // ------------------------------------------------------------------------- const row = (label: string, value: string) => console.log(` ${label.padEnd(12)} ${value}`); const section = (title: string) => { console.log(); console.log(title); console.log(' ' + '─'.repeat(Math.max(20, title.length - 2))); }; console.log(`Network: ${opts.network}`); console.log(`Coin type: ${coinType}`); section('Seed (raw nsec bytes, used directly as the BIP-32 seed)'); row('hex', bytesToHex(seed)); section('BIP-32 master (m)'); row('xprv', master.privateExtendedKey); row('xpub', master.publicExtendedKey); section(`BIP-352 account (${accountPath})`); row('xprv', accountNode.privateExtendedKey); row('xpub', accountNode.publicExtendedKey); section(`Scan key (${scanPath})`); row('privkey', bytesToHex(scanNode.privateKey)); row('Bscan', bytesToHex(Bscan)); row('xprv', scanNode.privateExtendedKey); row('xpub', scanNode.publicExtendedKey); section(`Spend key (${spendPath})`); if (spendNode.privateKey) row('privkey', bytesToHex(spendNode.privateKey)); row('Bspend', bytesToHex(Bspend)); row('xprv', spendNode.privateExtendedKey); row('xpub', spendNode.publicExtendedKey); section('Silent payment address'); row('address', address); section('Watch-only scan key (bscan_priv ‖ Bspend, HRP "spscan")'); row('spscan key', spScanKey); console.log(); } main().catch((err: unknown) => { const msg = err instanceof Error ? err.message : String(err); console.error(`\nError: ${msg}`); Deno.exit(1); });