recovery: official 2018-19 DigiByte mobile wallets derived the master key with HMAC "DigiByte seed"

The BreadWallet-based DigiByte Android/iOS apps used HMAC-SHA512 key
"DigiByte seed" (not BIP32's "Bitcoin seed") when turning the BIP39 seed
into the master node, so the same 12 words give a different key tree and
a standard m/0' scan finds nothing. rootFromSeed() takes an optional HMAC
key; balance scan, history, send and the derivation-path search now
cover m/0' under both keys, and user-added extra paths carry the key.
This commit is contained in:
Digibyte.X 2026-10-02 17:44:16 +02:00
parent ad3923c18c
commit 989a2696c9
7 changed files with 95 additions and 46 deletions

View file

@ -1,9 +1,19 @@
import { BIP32Factory, type BIP32Interface } from 'bip32';
import * as ecc from '@bitcoinerlab/secp256k1';
import { hmac } from '@noble/hashes/hmac';
import { sha512 } from '@noble/hashes/sha512';
import { digibyte, DGB_COIN_TYPE, type Network } from './network.js';
const bip32 = BIP32Factory(ecc);
// 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.
export const HMAC_BITCOIN_SEED = 'Bitcoin seed';
export const HMAC_DIGIBYTE_SEED = 'DigiByte seed';
export type Purpose = 44 | 49 | 84 | 86;
export const PURPOSE_LABEL: Record<Purpose, string> = {
@ -16,8 +26,11 @@ export const PURPOSE_LABEL: Record<Purpose, string> = {
export function rootFromSeed(
seed: Buffer,
network: Network = digibyte,
hmacKey: string = HMAC_BITCOIN_SEED,
): BIP32Interface {
return bip32.fromSeed(seed, network);
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'.

View file

@ -1,8 +1,8 @@
import { useEffect, useMemo, useState } from 'react';
import { searchPaths, searchSingle, PATH_CANDIDATES, type PathHit, type PathCandidate } from './path-search.js';
import { searchPaths, searchSingle, PATH_CANDIDATES, candidateKey, type PathHit, type PathCandidate } from './path-search.js';
import { useActiveSeed } from './ActiveSeed.js';
import {
extrasKey, readExtras, addExtra, removeExtra, subscribeExtras,
extrasKey, readExtras, addExtra, removeExtra, subscribeExtras, extraKey,
type ExtraPath, type ExtraPathScript,
} from './wallet-extra-paths.js';
@ -29,13 +29,8 @@ import {
// Paths the default balance scan already covers — anything on these
// isn't news. Kept in sync with useWalletBalance.scanSeedViaP2p.
const DEFAULT_SCAN_PATHS = new Set([
"m/44'/20'/0'",
"m/49'/20'/0'",
"m/84'/20'/0'",
"m/86'/20'/0'",
"m/0'",
"m/44'/0'/0'",
]);
"m/44'/20'/0'", "m/49'/20'/0'", "m/84'/20'/0'", "m/86'/20'/0'", "m/0'", "m/44'/0'/0'",
].map((p) => candidateKey({ path: p })).concat([candidateKey({ path: "m/0'", hmac: 'DigiByte seed' })]));
const GAP = 100;
@ -82,7 +77,7 @@ export function PathSearchPanel(): JSX.Element | null {
const extrasByPath = useMemo(() => {
const m = new Map<string, ExtraPath>();
for (const e of extras) m.set(`${e.path}::${e.script}`, e);
for (const e of extras) m.set(extraKey(e), e);
return m;
}, [extras]);
@ -103,11 +98,11 @@ export function PathSearchPanel(): JSX.Element | null {
// Pre-populate every candidate as busy so all 11 rows appear right
// away — user sees the full plan before any query returns.
const init = new Map<string, RowState>();
for (const c of PATH_CANDIDATES) init.set(c.path, { busy: true });
for (const c of PATH_CANDIDATES) init.set(candidateKey(c), { busy: true });
setRows(init);
try {
await searchPaths(phrase, GAP, (hit) => {
setRow(hit.candidate.path, { hit, busy: false });
setRow(candidateKey(hit.candidate), { hit, busy: false });
});
} finally {
// Belt-and-suspenders: any still-busy row (should be none) is
@ -123,12 +118,12 @@ export function PathSearchPanel(): JSX.Element | null {
async function retryOne(c: PathCandidate): Promise<void> {
if (!phrase) return;
setRow(c.path, { busy: true });
setRow(candidateKey(c), { busy: true });
try {
const hit = await searchSingle(phrase, c, GAP);
setRow(c.path, { hit, busy: false });
setRow(candidateKey(c), { hit, busy: false });
} catch (e) {
setRow(c.path, {
setRow(candidateKey(c), {
hit: {
candidate: c, xpub: '', firstAddress: '',
txs: null, totalReceivedSat: null, balanceSat: null,
@ -141,12 +136,12 @@ export function PathSearchPanel(): JSX.Element | null {
function promote(c: PathCandidate): void {
if (!storageKey) return;
addExtra(storageKey, { path: c.path, script: c.script, label: c.label });
addExtra(storageKey, { path: c.path, script: c.script, label: c.label, ...(c.hmac ? { hmac: c.hmac } : {}) });
}
function drop(path: string, script: ExtraPathScript): void {
function drop(path: string, script: ExtraPathScript, hmac?: string): void {
if (!storageKey) return;
removeExtra(storageKey, path, script);
removeExtra(storageKey, path, script, hmac);
}
const hasSearchResults = rows.size > 0;
@ -172,7 +167,7 @@ export function PathSearchPanel(): JSX.Element | null {
</div>
<div className="path-search-table">
{extras.map((e) => (
<div key={`${e.path}::${e.script}`} className="path-search-row hit">
<div key={extraKey(e)} className="path-search-row hit">
<div className="path-search-main">
<span className="path-search-label">
{e.label ?? e.path} <span className="muted" style={{ fontWeight: 400 }}>· {SCRIPT_LABEL[e.script]}</span>
@ -180,7 +175,7 @@ export function PathSearchPanel(): JSX.Element | null {
<span className="path-search-result">
<button
className="text-btn"
onClick={() => drop(e.path, e.script)}
onClick={() => drop(e.path, e.script, e.hmac)}
style={{ fontSize: 12, color: 'var(--danger, #e05252)' }}
title={`Stop scanning ${e.path} for this wallet`}
>Remove</button>
@ -202,15 +197,15 @@ export function PathSearchPanel(): JSX.Element | null {
{hasSearchResults && (
<div className="path-search-table">
{PATH_CANDIDATES.map((c) => {
const row = rows.get(c.path);
const row = rows.get(candidateKey(c));
const hit = row?.hit;
const busy = row?.busy ?? false;
const isHit = (hit?.txs ?? 0) > 0;
const isErr = !!hit?.error;
const isDefault = DEFAULT_SCAN_PATHS.has(c.path);
const isAdded = extrasByPath.has(`${c.path}::${c.script}`);
const isDefault = DEFAULT_SCAN_PATHS.has(candidateKey(c));
const isAdded = extrasByPath.has(extraKey({ path: c.path, script: c.script, hmac: c.hmac }));
return (
<div key={c.path} className={`path-search-row ${isHit ? 'hit' : ''} ${isErr ? 'err' : ''}`}>
<div key={candidateKey(c)} className={`path-search-row ${isHit ? 'hit' : ''} ${isErr ? 'err' : ''}`}>
<div className="path-search-main">
<span className="path-search-label">{c.label}</span>
<span className="path-search-result">
@ -255,7 +250,7 @@ export function PathSearchPanel(): JSX.Element | null {
);
})}
{!runningAll && PATH_CANDIDATES.every((c) => {
const r = rows.get(c.path)?.hit;
const r = rows.get(candidateKey(c))?.hit;
return r && !r.error && (r.txs ?? 0) === 0;
}) && (
<div className="field-help" style={{ marginTop: 8 }}>

View file

@ -4,8 +4,7 @@ import { useSessionAuth } from './session-auth.js';
import {
seedFromPhrase, seedFromElectrumPhrase, rootFromSeed, accountNode,
addressNode, p2wpkhAddress, addressesForPubkey, digibyte,
importWifWithAddresses,
} from '@dgb-wallet/core';
importWifWithAddresses, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core';
import { readExtras, extrasKey, type ExtraPathScript } from './wallet-extra-paths.js';
import type { WalletKind } from './ActiveSeed.js';
import { buildPsbt, finalizeAndExtract, type Utxo } from '@dgb-wallet/psbt';
@ -734,7 +733,10 @@ async function buildFromSeed(
// Legacy mobile path m/0'/{0,1}/i (2018-19 breadwallet-based DigiByte
// apps): P2PKH keys, registered as purpose 44 so change policy and
// signing treat them exactly like BIP44 legacy coins.
for (const legacy of [root.deriveHardened(0), root.derivePath("m/44'/0'/0'")]) {
// The third entry is the official 2018-19 DigiByte mobile app's tree:
// master key from HMAC "DigiByte seed" (see core/hd.ts), same m/0' layout.
const rootDgb = rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED);
for (const legacy of [root.deriveHardened(0), root.derivePath("m/44'/0'/0'"), rootDgb.deriveHardened(0)]) {
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {
const node = addressNode(legacy, change, i);
@ -753,7 +755,8 @@ async function buildFromSeed(
'p2pkh': 44, 'p2sh-p2wpkh': 49, 'p2wpkh': 84,
};
for (const extra of extras) {
const acct = extra.path === 'm' ? root : root.derivePath(extra.path);
const base = extra.hmac ? rootFromSeed(seed, undefined, extra.hmac) : root;
const acct = extra.path === 'm' ? base : base.derivePath(extra.path);
const purpose = scriptToPurpose[extra.script];
for (const change of [0, 1] as const) {
for (let i = 0; i < GAP; i++) {

View file

@ -1,4 +1,4 @@
import { seedFromPhrase, rootFromSeed, accountXpub, addressNode, addressesForPubkey } from '@dgb-wallet/core';
import { seedFromPhrase, rootFromSeed, accountXpub, addressNode, addressesForPubkey, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core';
import bs58check from 'bs58check';
import { defaultBlockbookClient } from './blockbook-default.js';
@ -18,6 +18,13 @@ export interface PathCandidate {
script: 'p2pkh' | 'p2sh-p2wpkh' | 'p2wpkh';
label: string;
why: string;
/** Master-key HMAC string; absent = standard "Bitcoin seed". */
hmac?: string;
}
/** Stable identity for a candidate (two candidates can share a path). */
export function candidateKey(c: { path: string; hmac?: string }): string {
return `${c.path}|${c.hmac ?? ''}`;
}
export interface PathHit {
@ -34,7 +41,9 @@ export const PATH_CANDIDATES: PathCandidate[] = [
{ path: "m/44'/20'/0'", script: 'p2pkh', label: "BIP44 · DGB (m/44'/20'/0')", why: 'Standard. Coinomi, Exodus, Atomic, DigiByte Go, Edge, Guarda, Trust.' },
{ path: "m/49'/20'/0'", script: 'p2sh-p2wpkh', label: "BIP49 · DGB (m/49'/20'/0')", why: 'Standard P2SH-SegWit (S… / 3… addresses).' },
{ path: "m/84'/20'/0'", script: 'p2wpkh', label: "BIP84 · DGB (m/84'/20'/0')", why: 'Standard native SegWit (dgb1q…).' },
{ path: "m/0'", script: 'p2pkh', label: "Legacy mobile (m/0')", why: '2018-19 DigiByte Android/iOS (breadwallet fork) and older bitcoinj-based apps.' },
{ path: "m/0'", script: 'p2pkh', label: "Legacy DigiByte mobile (m/0', \"DigiByte seed\")", hmac: HMAC_DIGIBYTE_SEED,
why: 'The official 2018-19 DigiByte Android / iOS app (BreadWallet fork). It derived the master key with the HMAC string "DigiByte seed" instead of the BIP32 standard, so the same words give a different key tree.' },
{ path: "m/0'", script: 'p2pkh', label: "Legacy m/0' (standard key)", why: 'Early non-DigiByte BreadWallet forks and older bitcoinj-based apps.' },
{ path: "m/44'/0'/0'", script: 'p2pkh', label: "BIP44 · coin type 0 (m/44'/0'/0')", why: "Wallets that reused the Bitcoin coin type for every coin (Jaxx-era, some early multi-coin apps)." },
{ path: "m/49'/0'/0'", script: 'p2sh-p2wpkh', label: "BIP49 · coin type 0 (m/49'/0'/0')", why: 'Same mistake, SegWit variant.' },
{ path: "m/84'/0'/0'", script: 'p2wpkh', label: "BIP84 · coin type 0 (m/84'/0'/0')", why: 'Same mistake, native SegWit variant.' },
@ -98,8 +107,8 @@ export async function searchSingle(
candidate: PathCandidate,
gap: number,
): Promise<PathHit> {
const root = rootFromSeed(await seedFromPhrase(phrase));
return probeCandidate(root, candidate, gap);
const seed = await seedFromPhrase(phrase);
return probeCandidate(rootFromSeed(seed, undefined, candidate.hmac), candidate, gap);
}
/**
@ -118,14 +127,22 @@ export async function searchPaths(
candidates: PathCandidate[] = PATH_CANDIDATES,
concurrency = 3,
): Promise<PathHit[]> {
const root = rootFromSeed(await seedFromPhrase(phrase));
const seed = await seedFromPhrase(phrase);
// One root per HMAC key — the standard tree and the "DigiByte seed" tree.
const roots = new Map<string, ReturnType<typeof rootFromSeed>>();
const rootFor = (hmacKey?: string) => {
const k = hmacKey ?? '';
let r = roots.get(k);
if (!r) { r = rootFromSeed(seed, undefined, hmacKey); roots.set(k, r); }
return r;
};
const results: PathHit[] = new Array(candidates.length);
let next = 0;
async function worker(): Promise<void> {
while (true) {
const i = next++;
if (i >= candidates.length) return;
const hit = await probeCandidate(root, candidates[i], gap);
const hit = await probeCandidate(rootFor(candidates[i].hmac), candidates[i], gap);
results[i] = hit;
onRow(hit);
}

View file

@ -12,8 +12,7 @@ import { useEffect, useRef, useState } from 'react';
import { walletIdHash, cacheKey, readCache, writeCache, type CachedScan } from './scan-cache.js';
import {
seedFromPhrase, seedFromElectrumPhrase, rootFromSeed, accountNode, accountXpub as toXpub,
importWifWithAddresses,
} from '@dgb-wallet/core';
importWifWithAddresses, HMAC_DIGIBYTE_SEED } from '@dgb-wallet/core';
import { type BlockbookUtxo } from '@dgb-wallet/blockbook';
import { defaultBlockbookClient } from './blockbook-default.js';
import bs58check from 'bs58check';
@ -396,7 +395,17 @@ export function useWalletBalance(): {
xpub: toXpub(root.deriveHardened(0)),
purpose: 44,
account: 0,
label: "Legacy mobile m/0' (2018-19 app)",
label: "Legacy m/0' (standard key)",
});
// The official 2018-19 DigiByte Android / iOS app derived its master
// key with HMAC "DigiByte seed" — a different key tree from the same
// words. Same m/0'/{0,1}/i layout, P2PKH.
const rootDgb = rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED);
descriptors.push({
xpub: toXpub(rootDgb.deriveHardened(0)),
purpose: 44,
account: 0,
label: "Legacy DigiByte mobile m/0' (\"DigiByte seed\" key)",
});
// Coin type 0 (Bitcoin's) under BIP44 — several early multi-coin
// apps reused it for every asset. Same P2PKH scripts, different key.
@ -413,7 +422,8 @@ export function useWalletBalance(): {
// out in the diagnostics list.
const ek = await extrasKey(active!.phrase, active!.kind, active!.passphrase);
for (const extra of readExtras(ek)) {
const node = extra.path === 'm' ? root : root.derivePath(extra.path);
const base = extra.hmac ? rootFromSeed(seed, undefined, extra.hmac) : root;
const node = extra.path === 'm' ? base : base.derivePath(extra.path);
descriptors.push({
xpub: toXpub(node),
purpose: SCRIPT_TO_PURPOSE[extra.script],

View file

@ -16,7 +16,7 @@
import { useEffect, useRef, useState } from 'react';
import {
seedFromPhrase, rootFromSeed, accountNode, accountXpub as toXpub,
seedFromPhrase, rootFromSeed, accountNode, accountXpub as toXpub, HMAC_DIGIBYTE_SEED,
addressNode, addressesForPubkey, importWifWithAddresses,
} from '@dgb-wallet/core';
import {
@ -156,7 +156,8 @@ export function useWalletHistory(): {
const own = new Set<string>();
const accounts = [
...([44, 49, 84, 86] as const).map((purpose) => accountNode(root, purpose, 0)),
root.deriveHardened(0), // legacy mobile m/0' (breadwallet-style)
root.deriveHardened(0), // legacy m/0' (standard key)
rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0), // official 2018-19 DGB mobile ("DigiByte seed" key)
root.derivePath("m/44'/0'/0'"), // BIP44 with Bitcoin's coin type (early multi-coin apps)
];
for (const acct of accounts) {
@ -169,7 +170,8 @@ export function useWalletHistory(): {
}
const xpubs: Array<{ xpub: string; purpose: 44 | 49 | 84 | 86 }> = [
...([44, 49, 84, 86] as const).map((purpose) => ({ xpub: toXpub(accountNode(root, purpose, 0)), purpose })),
{ xpub: toXpub(root.deriveHardened(0)), purpose: 44 }, // legacy mobile m/0'
{ xpub: toXpub(root.deriveHardened(0)), purpose: 44 }, // legacy m/0' (standard key)
{ xpub: toXpub(rootFromSeed(seed, undefined, HMAC_DIGIBYTE_SEED).deriveHardened(0)), purpose: 44 }, // 2018-19 DGB mobile
{ xpub: toXpub(root.derivePath("m/44'/0'/0'")), purpose: 44 }, // BIP44 coin type 0
];
const results = await Promise.all(xpubs.map(async ({ xpub, purpose }) => {

View file

@ -28,6 +28,14 @@ export interface ExtraPath {
script: ExtraPathScript;
/** Optional display label; UI synthesizes one if absent. */
label?: string;
/** BIP32 master-key HMAC string. Absent = standard "Bitcoin seed". The
* 2018-19 DigiByte mobile wallets used "DigiByte seed". */
hmac?: string;
}
/** Identity of an extra path: path + script + hmac. */
export function extraKey(e: { path: string; script: string; hmac?: string }): string {
return `${e.path}::${e.script}::${e.hmac ?? ''}`;
}
const KEY_PREFIX = 'dgb-wallet:extra-paths:';
@ -66,13 +74,14 @@ export function writeExtras(storageKey: string, paths: ExtraPath[]): void {
export function addExtra(storageKey: string, entry: ExtraPath): boolean {
const existing = readExtras(storageKey);
if (existing.some((e) => e.path === entry.path && e.script === entry.script)) return false;
if (existing.some((e) => extraKey(e) === extraKey(entry))) return false;
writeExtras(storageKey, [...existing, entry]);
return true;
}
export function removeExtra(storageKey: string, path: string, script: ExtraPathScript): void {
writeExtras(storageKey, readExtras(storageKey).filter((e) => !(e.path === path && e.script === script)));
export function removeExtra(storageKey: string, path: string, script: ExtraPathScript, hmac?: string): void {
const k = extraKey({ path, script, hmac });
writeExtras(storageKey, readExtras(storageKey).filter((e) => extraKey(e) !== k));
}
/** Subscribe to any extras-list change (any wallet). Returns unsubscribe. */