Files
eranos/scripts/derive_vectors.mjs
T
Alex Gleason c7473f824b wallet: derive BIP-39 mnemonic from nsec (v2) so funds import into any BIP-39 wallet
The HD wallet seed is now BIP-39-compatible. Pipeline:

  entropy  = HKDF-SHA256(nsec, info="agora/v1", length=32)
  mnemonic = BIP-39 encoding of (entropy || checksum)  // 24 words
  seed     = PBKDF2-HMAC-SHA512(mnemonic, salt="mnemonic", iters=2048)

The 24 words import cleanly into Sparrow, Electrum, Trezor, Ledger,
BlueWallet, Phoenix, etc., at the BIP-86 / BIP-352 paths. HKDF domain
separation means a leaked mnemonic compromises only the wallet, not
the Nostr identity (unlike the raw nsec).

v1 derivation (nsec used directly as BIP-32 master seed) is retained
as migration-only code. A new /wallet/migrate-v1 page detects funds
at the legacy addresses and builds a single sweep PSBT to consolidate
them into the v2 wallet. A persistent banner on /wallet surfaces the
flow when v1 funds exist.

The mnemonic shows up in two places: a "Back up wallet" dialog on
/wallet, and a section in Profile -> Advanced next to the nsec
backup. nsec backup copy updated to explain the relationship.

Locked test vectors pin the entire derivation pipeline (nsec -> 24
words -> first BIP-86 address -> sp1q...) so any future drift fails
loudly. Regenerate via scripts/derive_vectors.mjs.

Other changes:
- Re-key SP storage NIP-78 d-tag to /v2 so v1 and v2 UTXOs do not mix
- Re-key the persisted receive-address cursor to :v2: namespace
- Relax SP spend-key helper to 16-64 byte seeds (BIP-32 range) so the
  migration sweep can sign with the legacy 32-byte v1 seed too
- Remove stale NIP-SP references from derivation comments (the draft
  was not relevant to our use case)
- Document the wallet derivation scheme in NIP.md
- Translate every new string to all 10 non-English locales
2026-05-24 15:39:22 -05:00

51 lines
1.9 KiB
JavaScript

import { hkdf } from '@noble/hashes/hkdf.js';
import { sha256 } from '@noble/hashes/sha2.js';
import { entropyToMnemonic, mnemonicToSeedSync } from '@scure/bip39';
import { wordlist } from '@scure/bip39/wordlists/english';
import { HDKey } from '@scure/bip32';
import { bech32m, hex } from '@scure/base';
import * as btc from '@scure/btc-signer';
// Test vector 1: all-zero nsec
const nsec1 = new Uint8Array(32);
// Test vector 2: nsec from a deterministic source — all 0x01s
const nsec2 = new Uint8Array(32).fill(0x01);
// Test vector 3: an actual realistic nsec
const nsec3 = hex.decode('67dea2ed018072d675f5415ecfaed7d2597555e202d85b3d65ea4e58d2d92ffa');
function deriveAll(nsec, label) {
const entropy = hkdf(sha256, nsec, undefined, 'agora/v1', 32);
const mnemonic = entropyToMnemonic(entropy, wordlist);
const seed = mnemonicToSeedSync(mnemonic);
const root = HDKey.fromMasterSeed(seed);
const account = root.derive("m/86'/0'/0'");
const receive0 = account.deriveChild(0).deriveChild(0);
const xonly = receive0.publicKey.slice(1, 33);
const { address } = btc.p2tr(xonly, undefined, btc.NETWORK);
const spendNode = root.derive("m/352'/0'/0'/0'/0");
const scanNode = root.derive("m/352'/0'/0'/1'/0");
const spendPub = spendNode.publicKey;
const scanPub = scanNode.publicKey;
const payload = new Uint8Array(66);
payload.set(scanPub, 0);
payload.set(spendPub, 33);
const words = [0, ...bech32m.toWords(payload)];
const spAddress = bech32m.encode('sp', words, 1023);
console.log(`\n--- ${label} ---`);
console.log(`nsec hex: ${hex.encode(nsec)}`);
console.log(`entropy: ${hex.encode(entropy)}`);
console.log(`mnemonic: ${mnemonic}`);
console.log(`seed: ${hex.encode(seed)}`);
console.log(`xpub: ${account.publicExtendedKey}`);
console.log(`addr 0/0: ${address}`);
console.log(`sp addr: ${spAddress}`);
}
deriveAll(nsec1, 'all-zero nsec');
deriveAll(nsec2, 'all-0x01 nsec');
deriveAll(nsec3, 'realistic nsec');