c7473f824b
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
51 lines
1.9 KiB
JavaScript
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');
|