diff --git a/bundled-addons/aegis/index.js b/bundled-addons/aegis/index.js index 25db68a9..abb5d5c0 100644 --- a/bundled-addons/aegis/index.js +++ b/bundled-addons/aegis/index.js @@ -35,8 +35,11 @@ let ctx = null; async function loadDeps(api) { const { secp256k1 } = await api.import("@noble/curves/secp256k1.js"); const { ed25519 } = await api.import("@noble/curves/ed25519.js"); - const { sha256 } = await api.import("@noble/hashes/sha2.js"); + const { sha256, sha512 } = await api.import("@noble/hashes/sha2.js"); const { hkdf } = await api.import("@noble/hashes/hkdf.js"); + // For DigiByte's legacy master-key derivation — see HMAC_DIGIBYTE_SEED in + // lib/dgb/core/hd.js. + const { hmac } = await api.import("@noble/hashes/hmac.js"); const { ripemd160 } = await api.import("@noble/hashes/legacy.js"); const { keccak_256 } = await api.import("@noble/hashes/sha3.js"); const { blake2b } = await api.import("@noble/hashes/blake2.js"); @@ -89,6 +92,7 @@ async function loadDeps(api) { bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, bip39: api.require("bip39"), + hmac, sha512, }); dgbCore = await import(pathToFileURL(path.join(api.folder, "lib/dgb/core/index.js")).href); dgbPsbt = await import(pathToFileURL(path.join(api.folder, "lib/dgb/psbt/index.js")).href); @@ -125,7 +129,7 @@ async function loadDeps(api) { // chain-native private key). Used by the importWallet handler to compute // the address client-side before wallet-imports.enc stores the material. const derive = require("./lib/import-derive.js")({ - HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, + HDKey, secp256k1, ed25519, sha256, sha512, hmac, ripemd160, keccak_256, blake2b, cashaddr, base58check, bitcoinjs, bip32Factory: BIP32Factory, ecpairFactory: ECPairFactory, ecc, bip39, dgbCore, }); @@ -1220,7 +1224,19 @@ function registerPanelMessages(api) { } else if (p && p.mnemonic) { spec.kind = "seed"; spec.seed = der.mnemonicToSeedHex(String(p.mnemonic).trim()); spec.path = String(p.path || defaults[chain].path); - address = chain === "btc" ? der.btc.fromSeed(spec.seed, spec.path, net) : der.dgb.fromSeed(spec.seed, spec.path); + if (chain === "btc") address = der.btc.fromSeed(spec.seed, spec.path, net); + else { + // A DigiByte seed can belong to the 2018-19 official mobile + // wallets, which built the master node with HMAC key + // "DigiByte seed". Carry the choice onto the spec so the entry + // records which tree the stored address came from. + const hk = String((p && p.hmacKey) || der.dgb.HMAC_BITCOIN_SEED); + if (hk !== der.dgb.HMAC_BITCOIN_SEED && hk !== der.dgb.HMAC_DIGIBYTE_SEED) { + throw new Error("unknown DigiByte master-key variant"); + } + if (hk !== der.dgb.HMAC_BITCOIN_SEED) spec.hmacKey = hk; + address = der.dgb.fromSeed(spec.seed, spec.path, hk); + } } else { throw new Error("supply mnemonic or wif"); } // Theseus's api.vault.imports.add validates a `cashaddr` field (from // when only BCH imports existed). Reuse the same field name for @@ -2132,6 +2148,19 @@ function registerPanelMessages(api) { { path: "m/44'/145'/0'", note: "account node — some QR exports" }, { path: "m/0'/0/0", note: "pre-BIP44" }, ], + // DigiByte. Both master-key variants are covered, because the 2018-19 + // official mobile wallets (BreadWallet forks) used HMAC "DigiByte seed" + // instead of BIP32's "Bitcoin seed" — the same words then produce a + // completely different key tree and a standard scan finds nothing. + // Finding from Digibyte.X/dgb-wallet @ 989a2696. + "dgb/mainnet": [ + { path: "m/84'/20'/0'/0/0", note: "BIP84 native SegWit — dgb1q…, the modern default" }, + { path: "m/44'/20'/0'/0/0", note: "BIP44 legacy — D…" }, + { path: "m/49'/20'/0'/0/0", note: "BIP49 wrapped SegWit — S…" }, + { path: "m/86'/20'/0'/0/0", note: "BIP86 Taproot — dgb1p…" }, + { path: "m/0'/0/0", note: "official 2018-19 mobile wallet", hmacKey: "DigiByte seed" }, + { path: "m/44'/20'/0'/0/0", note: "BIP44 with the 2018-19 mobile master key", hmacKey: "DigiByte seed" }, + ], "bch/chipnet": [ { path: "m/44'/145'/0'/0/0", note: "BCH coin type on chipnet — most chipnet tooling" }, { path: "m/44'/1'/0'/0/0", note: "BIP44 testnet coin type" }, @@ -2141,7 +2170,7 @@ function registerPanelMessages(api) { api.onMessage("seedPathCandidates", (p, m) => { fromPanel(m); const key = `${String(p && p.chain || "")}/${String(p && p.network || "")}`; - return { key, candidates: SEED_PATH_CANDIDATES[key] || [] }; + return { key, candidates: (SEED_PATH_CANDIDATES[key] || []).map((c) => ({ ...c })) }; }); api.onMessage("scanSeedPaths", async (p, m) => { @@ -2158,20 +2187,39 @@ function registerPanelMessages(api) { try { seedHex = ctx.d.derive.mnemonicToSeedHex(mnemonic); } catch (e) { throw new Error("That does not look like a BIP39 seed phrase: " + (e?.message || e)); } - const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; - const servers = network === "mainnet" - ? bchServerList(api) - : ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"]; + // Per-chain: where to ask, how to turn a path into an address, and how to + // key a script for Electrum. Everything below is chain-agnostic. + let servers, addressAt, scripthashOf; + if (chain === "bch") { + const prefix = network === "mainnet" ? "bitcoincash" : "bchtest"; + servers = network === "mainnet" + ? bchServerList(api) + : ["wss://chipnet.imaginary.cash:50004", "wss://chipnet.bch.ninja:50004"]; + addressAt = (pth) => deriveCashaddrFromSeed(seedHex, pth, prefix); + scripthashOf = (addr) => { + const d = ctx.d.cashaddr.decode(addr); + const h = Buffer.from(d.hash); + const spk = d.type === 1 + ? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])]) + : Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]); + return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); + }; + } else if (chain === "dgb") { + if (!ctx.d.dgbCore) throw new Error("the DigiByte modules did not load, so paths cannot be scanned"); + servers = ["wss://electrum1.cipig.net:20063", "wss://electrum2.cipig.net:20063"]; + // hmacKey rides on the candidate, so both master-key variants are + // scanned in the same pass. + addressAt = (pth, hmacKey) => ctx.d.derive.dgb.fromSeed(seedHex, pth, hmacKey); + scripthashOf = (addr) => { + // bitcoinjs knows every DGB output type (D…, S…, dgb1q…, dgb1p…), + // so let it build the scriptPubKey rather than hand-rolling four. + const spk = ctx.d.bitcoinjs.address.toOutputScript(addr, ctx.d.dgbCore.digibyte); + return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); + }; + } else { + throw new Error(`Path scanning is not available for ${chain} yet.`); + } const client = new ctx.d.electrum.Client(servers); - // Electrum keys a script by sha256(scriptPubKey), little-endian. - const scripthashOf = (addr) => { - const d = ctx.d.cashaddr.decode(addr); - const h = Buffer.from(d.hash); - const spk = d.type === 1 - ? Buffer.concat([Buffer.from([0xa9, 0x14]), h, Buffer.from([0x87])]) - : Buffer.concat([Buffer.from([0x76, 0xa9, 0x14]), h, Buffer.from([0x88, 0xac])]); - return Buffer.from(ctx.d.sha256(new Uint8Array(spk))).reverse().toString("hex"); - }; // Look a few addresses deep per candidate: a wallet whose first receive // address is spent clean still has history, and funds often sit further // along the branch. @@ -2188,7 +2236,7 @@ function registerPanelMessages(api) { let balance = 0, txCount = 0, firstAddress = null, fundedAddress = null; for (const [idx, pth] of probes.entries()) { let addr; - try { addr = deriveCashaddrFromSeed(seedHex, pth, prefix); } + try { addr = addressAt(pth, c.hmacKey); } catch { continue; } if (idx === 0 || firstAddress === null) firstAddress = firstAddress || addr; const sh = scripthashOf(addr); @@ -2202,7 +2250,7 @@ function registerPanelMessages(api) { } catch (e) { api.log("scanSeedPaths:", pth, e?.message || e); } } out.push({ - path: c.path, note: c.note, accountPath: acct, + path: c.path, note: c.note, accountPath: acct, hmacKey: c.hmacKey || null, firstAddress, fundedAddress, balance, txCount, scanned: probes.length, }); } diff --git a/bundled-addons/aegis/lib/dgb/core/hd.js b/bundled-addons/aegis/lib/dgb/core/hd.js index 12063427..c1c9f11c 100644 --- a/bundled-addons/aegis/lib/dgb/core/hd.js +++ b/bundled-addons/aegis/lib/dgb/core/hd.js @@ -1,13 +1,23 @@ -import { bip32 } from '../deps.js'; +import { bip32, hmac, sha512 } from '../deps.js'; import { digibyte, DGB_COIN_TYPE } from './network.js'; +// BIP32 derives the master key as HMAC-SHA512(key, seed) with the key +// "Bitcoin seed". DigiByte's 2018-19 official mobile wallets (BreadWallet +// forks) used "DigiByte seed" instead, so the SAME 12 words give a +// completely different key tree — every address differs. A recovery that +// only tries the standard key never finds those coins. +// Ported from Digibyte.X/dgb-wallet @ 989a2696. +export const HMAC_BITCOIN_SEED = 'Bitcoin seed'; +export const HMAC_DIGIBYTE_SEED = 'DigiByte seed'; export const PURPOSE_LABEL = { 44: 'BIP44 legacy P2PKH', 49: 'BIP49 P2SH-wrapped SegWit', 84: 'BIP84 native SegWit v0', 86: 'BIP86 Taproot (SegWit v1)', }; -export function rootFromSeed(seed, network = digibyte) { - return bip32().fromSeed(seed, network); +export function rootFromSeed(seed, network = digibyte, hmacKey = HMAC_BITCOIN_SEED) { + if (hmacKey === HMAC_BITCOIN_SEED) return bip32().fromSeed(seed, network); + const I = hmac()(sha512(), new TextEncoder().encode(hmacKey), seed); + return bip32().fromPrivateKey(Buffer.from(I.slice(0, 32)), Buffer.from(I.slice(32)), network); } // Standard account-level derivation: m/purpose'/coin'/account'. // account defaults to 0 (the first account). diff --git a/bundled-addons/aegis/lib/dgb/deps.js b/bundled-addons/aegis/lib/dgb/deps.js index dcc4eb71..07b6368c 100644 --- a/bundled-addons/aegis/lib/dgb/deps.js +++ b/bundled-addons/aegis/lib/dgb/deps.js @@ -19,7 +19,7 @@ let deps = null; export function setDgbDeps(d) { - const need = ["bitcoinjs", "ecc", "bip32Factory", "ecpairFactory", "bip39"]; + const need = ["bitcoinjs", "ecc", "bip32Factory", "ecpairFactory", "bip39", "hmac", "sha512"]; for (const k of need) { if (!d || !d[k]) throw new Error(`setDgbDeps: missing ${k}`); } @@ -36,6 +36,8 @@ export const payments = () => need().bitcoinjs.payments; export const Psbt = () => need().bitcoinjs.Psbt; export const ecc = () => need().ecc; export const bip39 = () => need().bip39; +export const hmac = () => need().hmac; +export const sha512 = () => need().sha512; // initEccLib must run before any Taproot derivation, and exactly once. let eccInstalled = false; diff --git a/bundled-addons/aegis/lib/import-derive.js b/bundled-addons/aegis/lib/import-derive.js index be171a78..f5bef35d 100644 --- a/bundled-addons/aegis/lib/import-derive.js +++ b/bundled-addons/aegis/lib/import-derive.js @@ -7,7 +7,7 @@ // npm packages — same "hand it in" pattern the other adapters use. module.exports = function makeImportDerive({ - HDKey, secp256k1, ed25519, sha256, ripemd160, keccak_256, blake2b, + HDKey, secp256k1, ed25519, sha256, sha512, hmac, ripemd160, keccak_256, blake2b, cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39, dgbCore, }) { @@ -15,6 +15,17 @@ module.exports = function makeImportDerive({ // imported SC wallets end up with byte-identical addresses to what // Sia Central Lite or walletd would show for the same seed. const sia = blake2b ? require("./sia/sia.js")({ ed25519, blake2b }) : null; + // Taproot needs bitcoinjs-lib's schnorr backend installed via initEccLib, + // which is process-global and must happen once. This module used to rely on + // chain-btc.js (and, before it went lazy, lib/dgb/core/address.js) doing it + // at load time — a hidden dependency on an unrelated module's import order, + // which broke the moment either stopped being eagerly loaded. Own it here. + let eccInstalled = false; + function ensureEcc() { + if (eccInstalled) return; + try { if (bitcoinjs && bitcoinjs.initEccLib && ecc) bitcoinjs.initEccLib(ecc); } catch { /* p2tr will report it */ } + eccInstalled = true; + } const toHex = (b) => Array.from(b, (x) => x.toString(16).padStart(2, "0")).join(""); const fromHex = (h) => { const s = String(h || "").replace(/^0x/i, ""); @@ -49,6 +60,7 @@ module.exports = function makeImportDerive({ if (purpose === 86) { // Taproot — bitcoinjs.p2tr wants the 32-byte x-only pubkey. const xonly = pk.slice(1, 33); + ensureEcc(); return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address; } if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address; @@ -69,6 +81,7 @@ module.exports = function makeImportDerive({ const net = BTC_NET[network]; if (purpose === 86) { const xonly = pk.slice(1, 33); + ensureEcc(); return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address; } if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address; @@ -81,15 +94,29 @@ module.exports = function makeImportDerive({ if (!dgbCore) throw new Error("DGB adapter not available"); return dgbCore.digibyte; } - function deriveDgbFromSeed(seedHex, path) { + // DigiByte's 2018-19 official mobile wallets (BreadWallet forks) built the + // master node with HMAC-SHA512 key "DigiByte seed" rather than BIP32's + // "Bitcoin seed", so the same words yield a different key tree and a + // standard scan finds nothing. Pass hmacKey to recover those. + // Ported from Digibyte.X/dgb-wallet @ 989a2696. + const HMAC_BITCOIN_SEED = "Bitcoin seed"; + const HMAC_DIGIBYTE_SEED = "DigiByte seed"; + function dgbRootFromSeed(seedBytes, net, hmacKey) { const bip32 = bip32Factory(ecc); + if (!hmacKey || hmacKey === HMAC_BITCOIN_SEED) return bip32.fromSeed(Buffer.from(seedBytes), net); + if (!hmac || !sha512) throw new Error("this build cannot derive the legacy DigiByte master key"); + const I = hmac(sha512, new TextEncoder().encode(hmacKey), Buffer.from(seedBytes)); + return bip32.fromPrivateKey(Buffer.from(I.slice(0, 32)), Buffer.from(I.slice(32)), net); + } + function deriveDgbFromSeed(seedHex, path, hmacKey) { const net = digibyteNetwork(); - const node = bip32.fromSeed(Buffer.from(fromHex(seedHex)), net).derivePath(path); + const node = dgbRootFromSeed(fromHex(seedHex), net, hmacKey).derivePath(path); const pk = Buffer.from(node.publicKey); const m = /^m\/(\d+)'/.exec(String(path || "")); const purpose = m ? Number(m[1]) : 84; if (purpose === 86) { const xonly = pk.slice(1, 33); + ensureEcc(); return bitcoinjs.payments.p2tr({ internalPubkey: xonly, network: net }).address; } if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address; @@ -102,6 +129,7 @@ module.exports = function makeImportDerive({ const kp = ECPair.fromWIF(wif, net); const purpose = hint || 84; const pk = kp.publicKey; + ensureEcc(); if (purpose === 86) return bitcoinjs.payments.p2tr({ internalPubkey: pk.slice(1, 33), network: net }).address; if (purpose === 84) return bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }).address; if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address; @@ -250,7 +278,10 @@ module.exports = function makeImportDerive({ return { mnemonicToSeedHex, btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif }, - dgb: { fromSeed: deriveDgbFromSeed, fromWif: deriveDgbFromWif }, + dgb: { + fromSeed: deriveDgbFromSeed, fromWif: deriveDgbFromWif, + HMAC_BITCOIN_SEED, HMAC_DIGIBYTE_SEED, + }, eth: { fromSeed: deriveEthFromSeed, fromPrivHex: deriveEthFromPrivHex }, trx: { fromSeed: deriveTrxFromSeed, fromPrivHex: deriveTrxFromPrivHex }, sol: { fromSeed: deriveSolFromSeed, fromPrivHex: deriveSolFromPrivHex, fromBase58: deriveSolFromBase58 }, diff --git a/bundled-addons/aegis/panel.js b/bundled-addons/aegis/panel.js index 819ec08b..d5817974 100644 --- a/bundled-addons/aegis/panel.js +++ b/bundled-addons/aegis/panel.js @@ -1887,6 +1887,10 @@ function openImportModal(initialChain) { // Derivation-path presets + the "which path has my funds" scan. Both are // mnemonic-only: a WIF carries its own single key and has no path. const CUSTOM = "__custom__"; + // Which master-key variant the chosen path belongs to (DigiByte only; + // null means BIP32's standard "Bitcoin seed"). + let importHmacKey = null; + let lastCandidates = null; async function paintPathPresets() { const presetSel = overlay.querySelector("#imPathPreset"); const scanRow = overlay.querySelector("#imScanRow"); @@ -1900,22 +1904,33 @@ function openImportModal(initialChain) { try { cands = (await S.invoke("seedPathCandidates", { chain: curChain, network: netId }))?.candidates || []; } catch { cands = []; } } + lastCandidates = cands; if (!cands.length) { presetSel.hidden = true; scanRow.hidden = true; return; } presetSel.hidden = false; scanRow.hidden = false; const cur = overlay.querySelector("#imPath").value.trim(); - presetSel.innerHTML = cands.map((c) => - `` + // The value is an index, not the path: DigiByte lists the same path + // twice under two different master keys, so the path alone is not a + // unique choice. + presetSel.innerHTML = cands.map((c, i) => + `` ).join("") + ``; presetSel.onchange = () => { if (presetSel.value === CUSTOM) { overlay.querySelector("#imPath").focus(); return; } - overlay.querySelector("#imPath").value = presetSel.value; + const c = cands[Number(presetSel.value)]; + if (!c) return; + overlay.querySelector("#imPath").value = c.path; + importHmacKey = c.hmacKey || null; }; // Typing by hand flips the select to Custom rather than leaving it // pointing at a preset the field no longer matches. overlay.querySelector("#imPath").oninput = () => { const v = overlay.querySelector("#imPath").value.trim(); - presetSel.value = cands.some((c) => c.path === v) ? v : CUSTOM; + const i = cands.findIndex((c) => c.path === v && !c.hmacKey); + presetSel.value = i >= 0 ? String(i) : CUSTOM; + // Typing a path by hand means the standard master key unless a scan + // result sets it again. + if (i >= 0) importHmacKey = null; }; } @@ -1944,10 +1959,10 @@ function openImportModal(initialChain) { : `nothing`; return `