theseus/bundled-addons/aegis/lib/import-derive.js
Local Dev 469ff482ea feat(aegis): recover DigiByte seeds from the 2018-19 mobile wallets
Ported from Digibyte.X/dgb-wallet @ 989a2696.

BIP32 builds the master key as HMAC-SHA512("Bitcoin seed", seed). DigiByte's
official 2018-19 Android/iOS wallets were BreadWallet forks and used
"DigiByte seed" as the HMAC key instead, so the same twelve words produce a
completely different key tree — every address differs, and a standard scan
finds nothing at all. Anyone importing one of those seeds into Aegis got a
valid, empty address and a zero balance with no way to tell why. Same shape
as the Bitcoin.com coin-type-0 problem.

- rootFromSeed() in the vendored lib/dgb/core/hd.js takes an optional HMAC
  key, matching upstream, and exports HMAC_BITCOIN_SEED /
  HMAC_DIGIBYTE_SEED.
- lib/import-derive.js grows the same option, since that is what actually
  derives the address on an import, and importWallet records the variant on
  the spec so the entry says which tree its stored address came from.
- The path scanner added in 0.24.0 now covers DigiByte: all four purposes
  under the standard key, plus m/0' and BIP44 under the legacy one. So the
  answer to "which tree holds my coins" is a scan rather than a guess, and
  the Use button carries the key along with the path.

Verified against an independent HMAC-SHA512 computation, and end to end: the
same mnemonic gives DG1Khh…N1i under the standard key and DMWQ1g…PHse under
the DigiByte key, both valid, with m/0' deriving DGAf4M…Wyn.

Also fixes a coupling this exposed: lib/import-derive.js derives taproot
addresses but never called initEccLib, relying on chain-btc.js (and formerly
lib/dgb/core/address.js, before it went lazy here) doing it at load time. It
now installs the schnorr backend itself, once, so its taproot output no
longer depends on an unrelated module's import order.

The gap-limit change in 0a5d7ade (scan gap 200/100 for DigiScope parity) is
upstream-only for now — Aegis's imported DGB adapter watches a single stored
address rather than scanning a gap.
2026-10-03 14:13:25 +02:00

290 lines
14 KiB
JavaScript

// Per-chain address derivation for imported wallets. Every helper turns
// either a BIP39 mnemonic (+ path) OR a raw private key (chain-native
// format — WIF for UTXO chains, hex for account chains, base58 for Solana)
// into the canonical address that chain uses.
//
// Deps arrive from index.js loadDeps() so nothing here has to know about
// npm packages — same "hand it in" pattern the other adapters use.
module.exports = function makeImportDerive({
HDKey, secp256k1, ed25519, sha256, sha512, hmac, ripemd160, keccak_256, blake2b,
cashaddr, base58check, bitcoinjs, bip32Factory, ecpairFactory, ecc, bip39,
dgbCore,
}) {
// Sia's key derivation lives in lib/sia/sia.js — reuse it here so
// 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, "");
const out = new Uint8Array(s.length / 2);
for (let i = 0; i < out.length; i++) out[i] = parseInt(s.substr(i * 2, 2), 16);
return out;
};
const hash160 = (b) => ripemd160(sha256(b));
// BIP39 mnemonic → 64-byte seed hex. Same wire format the vault-derive
// path stores, so keystore-mirrored seeds land in wallet-imports.enc
// identically whether they came from a mnemonic or hex directly.
function mnemonicToSeedHex(m) {
if (!bip39.validateMnemonic(m)) throw new Error("invalid BIP39 mnemonic");
return toHex(bip39.mnemonicToSeedSync(m));
}
// ---- BTC ------------------------------------------------------------------
const BTC_NET = {
mainnet: bitcoinjs.networks.bitcoin,
testnet3: bitcoinjs.networks.testnet,
signet: bitcoinjs.networks.testnet, // signet uses testnet params here
};
function btcAddressFromNode(node, path, network) {
const net = BTC_NET[network];
if (!net) throw new Error(`unknown BTC network ${network}`);
// Purpose byte in the path decides the address type. m/84' -> bech32,
// m/49' -> P2SH-P2WPKH, m/86' -> P2TR, m/44' -> P2PKH.
const m = /^m\/(\d+)'/.exec(String(path || ""));
const purpose = m ? Number(m[1]) : 84;
const pk = Buffer.from(node.publicKey);
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;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
function deriveBtcFromSeed(seedHex, path, network) {
const bip32 = bip32Factory(ecc);
const node = bip32.fromSeed(Buffer.from(fromHex(seedHex)), BTC_NET[network]).derivePath(path);
return btcAddressFromNode(node, path, network);
}
function deriveBtcFromWif(wif, network, hint) {
const ECPair = ecpairFactory(ecc);
const kp = ECPair.fromWIF(wif, BTC_NET[network]);
// WIF alone doesn't tell us the address family; caller passes hint = 44/49/84/86.
const purpose = hint || 84;
const pk = kp.publicKey;
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;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
// ---- DGB (mirrors BTC pattern with digibyte params) -----------------------
function digibyteNetwork() {
if (!dgbCore) throw new Error("DGB adapter not available");
return dgbCore.digibyte;
}
// 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 = 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;
if (purpose === 49) return bitcoinjs.payments.p2sh({ redeem: bitcoinjs.payments.p2wpkh({ pubkey: pk, network: net }) }).address;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
function deriveDgbFromWif(wif, hint) {
const ECPair = ecpairFactory(ecc);
const net = digibyteNetwork();
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;
return bitcoinjs.payments.p2pkh({ pubkey: pk, network: net }).address;
}
// ---- ETH (EIP-55 checksummed 0x address) ----------------------------------
function ethAddressFromPubkey(pubUncompressed64) {
// Strip the 0x04 prefix if present so we hash just the 64 raw bytes.
const raw = pubUncompressed64.length === 65 ? pubUncompressed64.slice(1) : pubUncompressed64;
const h = keccak_256(raw);
const addr20 = h.slice(-20);
const hex = toHex(addr20);
// EIP-55 checksum
const hashOfLower = toHex(keccak_256(new TextEncoder().encode(hex)));
let out = "0x";
for (let i = 0; i < hex.length; i++) {
out += parseInt(hashOfLower[i], 16) >= 8 ? hex[i].toUpperCase() : hex[i];
}
return out;
}
function deriveEthFromSeed(seedHex, path) {
const node = HDKey.fromMasterSeed(fromHex(seedHex)).derive(path);
// secp256k1.getPublicKey with compressed=false gives 65 bytes (04||X||Y).
const pub = secp256k1.getPublicKey(node.privateKey, false);
return ethAddressFromPubkey(pub);
}
function deriveEthFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32) throw new Error("ETH private key must be 32 bytes hex");
const pub = secp256k1.getPublicKey(priv, false);
return ethAddressFromPubkey(pub);
}
// ---- TRX (T... base58check, network 0x41) --------------------------------
function tronAddressFromPubkey(pubUncompressed65) {
const raw = pubUncompressed65.length === 65 ? pubUncompressed65.slice(1) : pubUncompressed65;
const h = keccak_256(raw);
const last20 = h.slice(-20);
const versioned = new Uint8Array(21);
versioned[0] = 0x41; // Tron mainnet address prefix — same for Nile testnet
versioned.set(last20, 1);
return base58check.encodeCheck(versioned);
}
function deriveTrxFromSeed(seedHex, path) {
const node = HDKey.fromMasterSeed(fromHex(seedHex)).derive(path);
const pub = secp256k1.getPublicKey(node.privateKey, false);
return tronAddressFromPubkey(pub);
}
function deriveTrxFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32) throw new Error("TRX private key must be 32 bytes hex");
const pub = secp256k1.getPublicKey(priv, false);
return tronAddressFromPubkey(pub);
}
// ---- SOL (base58 pubkey, ed25519) ----------------------------------------
// SLIP-0010 ed25519 hardened derivation. Slightly different HD scheme
// from BIP32 secp256k1 — every step is hardened, index >= 0x80000000.
function slip0010DeriveEd25519(seed, path) {
const HMAC_KEY = new TextEncoder().encode("ed25519 seed");
const parts = String(path).split("/").slice(1);
// Compute master
const enc = new (require("crypto")).createHmac ? require("crypto") : null;
// Not using node crypto — the deps hand in @noble/hashes hmac via sha512.
// We rely on secp256k1's helpers? No — use ed25519 utils.
// Simplified: compute HMAC-SHA512(HMAC_KEY, seed) → I=I_L||I_R, sk=I_L, cc=I_R.
// Then each step: HMAC-SHA512(cc, 0x00 || sk || idx).
// Implementation via @noble/hashes/hmac imported as `hmacSha512`. We
// require it lazily so unavailable deps error out here rather than at
// load time.
const { hmac } = require("@noble/hashes/hmac");
const { sha512 } = require("@noble/hashes/sha2");
let I = hmac(sha512, HMAC_KEY, seed);
let sk = I.slice(0, 32); let cc = I.slice(32);
for (const seg of parts) {
const m = /^(\d+)'?$/.exec(seg);
if (!m) throw new Error(`bad path segment: ${seg}`);
const idx = (Number(m[1]) | 0x80000000) >>> 0;
const data = new Uint8Array(1 + 32 + 4);
data[0] = 0;
data.set(sk, 1);
data[33] = (idx >>> 24) & 0xff; data[34] = (idx >>> 16) & 0xff;
data[35] = (idx >>> 8) & 0xff; data[36] = idx & 0xff;
I = hmac(sha512, cc, data);
sk = I.slice(0, 32); cc = I.slice(32);
}
return sk;
}
function deriveSolFromSeed(seedHex, path) {
const sk = slip0010DeriveEd25519(fromHex(seedHex), path);
const pub = ed25519.getPublicKey(sk);
return base58check.encodeBase58(pub);
}
function deriveSolFromPrivHex(hex) {
const priv = fromHex(hex);
if (priv.length !== 32 && priv.length !== 64) throw new Error("SOL private key must be 32 or 64 bytes hex");
const seed = priv.length === 64 ? priv.slice(0, 32) : priv;
const pub = ed25519.getPublicKey(seed);
return base58check.encodeBase58(pub);
}
function deriveSolFromBase58(b58) {
const bytes = base58check.decodeBase58(b58);
if (bytes.length !== 32 && bytes.length !== 64) throw new Error("SOL private key base58 must decode to 32 or 64 bytes");
const seed = bytes.length === 64 ? bytes.slice(0, 32) : bytes;
const pub = ed25519.getPublicKey(seed);
return base58check.encodeBase58(pub);
}
// ---- SC (Siacoin) --------------------------------------------------------
// Sia's walletd + Sia Central Lite Wallet both use a 32-byte root seed.
// Sia Central Lite exports it as a BIP39 12-word mnemonic (PBKDF2 →
// 64-byte seed → first 32 bytes = root); walletd's API accepts the raw
// 32-byte hex. Address at index N: standardUnlockHash(ed25519.pub(
// blake2b(root32 || u64le(N))
// )) — see lib/sia/sia.js:keyFromSeed for the byte layout.
function deriveScRootFromMnemonic(m) {
// BIP39 → 512-bit master seed; Sia Central takes the FIRST 32 bytes as
// the walletd root. Trimming the tail keeps addresses identical to
// what sialite.com and Sia Central mobile derive for the same phrase.
const fullSeedHex = mnemonicToSeedHex(m);
return fullSeedHex.slice(0, 64);
}
function deriveScRootFromHex(seedHex) {
const s = String(seedHex || "").trim().toLowerCase().replace(/^0x/, "");
if (!/^[0-9a-f]{64}$/.test(s)) throw new Error("SC seed hex must be exactly 32 bytes (64 hex chars)");
return s;
}
function deriveScAddressFromSeed(seedHex, index) {
if (!sia) throw new Error("SC derive unavailable (blake2b dep not passed)");
const root = fromHex(seedHex);
if (root.length !== 32) throw new Error("SC root seed must be 32 bytes");
const idx = Number(index || 0);
if (!Number.isInteger(idx) || idx < 0) throw new Error("SC index must be a non-negative integer");
const k = sia.keyFromSeed(root, idx);
return k.address; // 76 hex chars, canonical Sia address form
}
function deriveScFromMnemonic(mnemonic, index) {
return { seedHex: deriveScRootFromMnemonic(mnemonic), address: deriveScAddressFromSeed(deriveScRootFromMnemonic(mnemonic), index) };
}
function deriveScFromSeedHex(seedHex, index) {
const s = deriveScRootFromHex(seedHex);
return { seedHex: s, address: deriveScAddressFromSeed(s, index) };
}
return {
mnemonicToSeedHex,
btc: { fromSeed: deriveBtcFromSeed, fromWif: deriveBtcFromWif },
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 },
sc: { fromMnemonic: deriveScFromMnemonic, fromSeedHex: deriveScFromSeedHex, addressAt: deriveScAddressFromSeed },
};
};