Web3 и блокчейн Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа
0%

Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа

Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа

В Криптографии Web3 мы выяснили главное: приватный ключ — это и есть аккаунт. Нет логина, нет пароля, нет службы поддержки. Кошелёк — программа, которая помогает человеку управлять числом d длиной 32 байта так, чтобы его не потерять и не отдать посторонним. Всё остальное в интерфейсе — балансы, история, названия токенов — читается из блокчейна публично и само по себе ценности не имеет.

Эта статья — про инженерное устройство слоя между человеком и d. Здесь больше всего мифов («seed — это мой пароль», «аппаратный кошелёк нельзя взломать», «я подключил кошелёк к сайту, значит меня уже могут обокрасть») и здесь же происходит подавляющее большинство реальных потерь. По оценке Chainalysis, около 20% всех существующих биткоинов лежат на адресах, доступ к которым утрачен навсегда: это следствие не взломов протокола, а того, как люди обращаются с ключами. Сразу о жанре: дальше нет ни слова о том, что покупать и куда пойдёт цена — только механика, форматы данных и компромиссы.

1. Кошелёк не хранит монеты — он хранит право подписи

Токенов «внутри кошелька» не существует. Баланс — запись в состоянии сети, привязанная к адресу (Ethereum и EVM). Кошелёк хранит секрет, позволяющий эту запись изменить, и умеет три вещи: породить или восстановить ключи, показать состояние, собрать и подписать сообщение.

Что Где физически лежит При утечке При потере
Мнемоника (12–24 слова) / seed бумага, металл, secure element, (плохо) облако полный контроль над всем деревом ключей доступ утрачен, если нет отдельных копий ключей
Приватный ключ адреса RAM, keystore-файл, чип контроль над одним адресом недоступен один адрес
Пароль от keystore голова, менеджер паролей нужен ещё и файл файл бесполезен
passphrase («25-е слово») голова, отдельный носитель нужна ещё и мнемоника доступ утрачен полностью

Дерево «энтропия → адреса» выглядит так, и каждая стрелка здесь односторонняя:

Ключевое наблюдение: шаги A→D происходят ровно один раз в жизни кошелька, шаги D→H детерминированы и воспроизводимы где угодно, а шаг J — единственный, который требует секрета в момент операции. Хорошая архитектура кошелька строится вокруг того, чтобы d участвовал только в шаге J и никогда не покидал место, где родился.

2. Энтропия: единственное место, где рождается настоящий секрет

Вся стойкость схемы задаётся на первом шаге. Ни BIP-39, ни BIP-32 энтропию не добавляют — они её только перекодируют. Если генератор случайных чисел плох, длина мнемоники не значит ничего. Три реальных провала:

  • Brainwallet. Ключ = sha256("моя любимая цитата"). Боты перебирали фразы из книг, песен и цитатников и опустошали такие адреса за минуты после первого поступления: человеческая память не источник 128 бит энтропии.
  • Profanity (2022). Популярный генератор «красивых» адресов инициализировал 256-битный ключ из 32-битного зерна: пространство поиска 2^32 вместо 2^256, перебор на GPU за часы. Через эту дыру у маркетмейкера Wintermute вывели около 160 млн USD.
  • Milk Sad (2023). Утилита bx seed из Libbitcoin Explorer брала зерно из 32-битного Mersenne Twister, засеянного системным временем; все сгенерированные ею за годы мнемоники восстанавливаются перебором миллиардов вариантов.

Правило инженера: энтропия только из криптографического источника (getrandom, crypto.getRandomValues, аппаратный TRNG в чипе кошелька), в Python — secrets, а не random:

entropy = secrets.token_bytes(16)   # ✅ 128 бит из CSPRNG ОС
bad     = bytes(random.getrandbits(8) for _ in range(16))          # ❌ MT19937 предсказуем
brain   = hashlib.sha256("correct horse battery staple".encode())  # ❌ десятки бит, не сотни

Сложность перебора: 128 бит — это 2^128 вариантов, и при фантастических 10^18 проверок в секунду поиск занял бы порядка 10^13 лет. 32 бита — 4·10^9 вариантов, то есть секунды. Разница не в «уровне защиты», а между «невозможно» и «уже украли».

3. BIP-39: как 128 бит превращаются в 12 слов

Мнемоника решает человеческую задачу: 32 байта hex переписать без ошибки невозможно, а 12 слов из фиксированного словаря — можно. Механика жёстко стандартизована (BIP-39): энтропия ENT из 128/160/192/224/256 бит дополняется контрольной суммой CS = ENT/32 бит (старшие биты SHA-256(ENT)), результат режется на группы по 11 бит, каждая группа — индекс в словаре из 2^11 = 2048 слов; итог прогоняется через PBKDF2 в 512-битный seed.

Битовая раскладка BIP-39: энтропия, контрольная сумма, слова и seed

def entropy_to_mnemonic(entropy: bytes, wordlist: list[str]) -> str:
    """ENT → мнемоника. O(ENT) по времени и памяти."""
    assert len(entropy) in (16, 20, 24, 28, 32), "допустимы 128..256 бит"
    cs_len = len(entropy) * 8 // 32                       # бит контрольной суммы
    bits = int.from_bytes(entropy, "big") << cs_len       # освобождаем место
    bits |= hashlib.sha256(entropy).digest()[0] >> (8 - cs_len)   # cs_len <= 8
    total = len(entropy) * 8 + cs_len
    return " ".join(wordlist[(bits >> (total - 11 * (i + 1))) & 0x7FF]
                    for i in range(total // 11))          # старшие 11 бит первыми

def mnemonic_to_seed(mnemonic: str, passphrase: str = "") -> bytes:
    """Мнемоника → seed. Без нормализации NFKD seed «поплывёт» на не-ASCII."""
    m = unicodedata.normalize("NFKD", " ".join(mnemonic.split()))
    salt = unicodedata.normalize("NFKD", "mnemonic" + passphrase)
    return hashlib.pbkdf2_hmac("sha512", m.encode(), salt.encode(), 2048, 64)

# Официальный нулевой тест-вектор (использовать в реальности НЕЛЬЗЯ):
print(mnemonic_to_seed("abandon " * 11 + "about").hex()[:32])  # 5eb00bbddcf06908...

Что из этого следует практически:

  • Контрольная сумма ловит опечатки, но не все. Случайные 12 слов валидны с вероятностью 1/16: кошелёк отвергнет опечатку в 15 случаях из 16, но перестановку слов может и пропустить. Порядок слов — это данные: поменяли два слова местами — получили другое дерево и другие адреса.
  • Словарь фиксирован. Первые четыре буквы каждого слова уникальны — на этом основан ввод по префиксу на аппаратных устройствах. Словари языков несовместимы: японская фраза не восстановится английским словарём.
  • PBKDF2 с 2048 итерациями — слабое место. Он защищает не энтропию (её перебрать невозможно), а passphrase; придуманную человеком passphrase перебирают тысячами вариантов в секунду на обычной видеокарте. Argon2 был бы уместнее, но формат зафиксирован в 2013 году и меняться не будет.
  • Passphrase создаёт другой кошелёк, а не «защищает» существующий. Её правильность не проверяет ничто: опечатка молча даёт пустой кошелёк с валидными адресами. Отсюда правило — сначала проверить восстановление на маленькой сумме.
  • Мнемоника — не ключ и не пароль. Пароль меняют, seed — нет: он навсегда определяет всё дерево, и компрометация означает миграцию всех активов на новое дерево.

4. BIP-32: дерево ключей из одного seed

Держать по независимому ключу на каждый адрес — операционный кошмар: новый бэкап после каждого адреса. BIP-32 решает это иерархической детерминированной (HD) деривацией: один seed порождает бесконечное дерево, бэкап делается один раз.

Дерево HD-кошелька и утечка мастер-ключа через xpub

Узел дерева — пара «приватный ключ k + chain code c» (32 + 32 байта). Chain code — дополнительная энтропия, без которой соседние ключи выводились бы друг из друга. Правила:

мастер:            I = HMAC-SHA512(key="Bitcoin seed", data=seed); k = I[:32], c = I[32:]
обычный потомок:   I = HMAC-SHA512(c, serP(K) ‖ ser32(i)),          i <  2^31
hardened-потомок:  I = HMAC-SHA512(c, 0x00 ‖ ser256(k) ‖ ser32(i)), i >= 2^31
итог:              k_i = (I[:32] + k) mod n,   c_i = I[32:]

Разница в одной строке, а последствия принципиальные. Для обычной деривации вход публичен — значит, зная расширенный публичный ключ xpub = (K, c), можно вычислить все дочерние публичные ключи, не имея ни одного приватного. Это и есть watch-only кошелёк и генерация адресов на сервере приёма платежей. Для hardened нужен приватный ключ родителя, поэтому от xpub вниз пройти нельзя.

N = 0xFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFFEBAAEDCE6AF48A03BBFD25E8CD0364141

def ser_p(pt) -> bytes:               # сжатый публичный ключ: 0x02/0x03 по чётности Y + X
    return bytes([2 + (pt[1] & 1)]) + pt[0].to_bytes(32, "big")

def ckd_priv(k: int, c: bytes, i: int):
    """Шаг деривации: один HMAC-SHA512 плюс умножение точки при i < 2^31."""
    if i >= 2**31:                                    # hardened
        data = b"\x00" + k.to_bytes(32, "big") + i.to_bytes(4, "big")
    else:                                             # обычный
        data = ser_p(mul(k)) + i.to_bytes(4, "big")   # mul — умножение на secp256k1
    I = hmac.new(c, data, hashlib.sha512).digest()
    IL = int.from_bytes(I[:32], "big")
    if IL >= N: raise ValueError("вероятность ~2⁻¹²⁷: берём следующий индекс")
    return (IL + k) % N, I[32:]

def derive(seed: bytes, path: str):
    """Путь m/44'/60'/0'/0/0. Глубина d → O(d) шагов, O(1) памяти."""
    I = hmac.new(b"Bitcoin seed", seed, hashlib.sha512).digest()
    k, c = int.from_bytes(I[:32], "big"), I[32:]
    for part in path.split("/")[1:]:
        i = int(part.rstrip("'")) + (2**31 if part.endswith("'") else 0)
        k, c = ckd_priv(k, c, i)
    return k, c

seed = mnemonic_to_seed("abandon " * 11 + "about")   # нулевой тест-вектор
k, _ = derive(seed, "m/44'/60'/0'/0/0")
print(hex(k))                          # 0x1ab42cc412b618bdea3a599e3c9bae19...
print(keccak256(raw_pub(mul(k)))[-20:].hex())   # 9858effd232b4033e47d90003d41ec34ecaeda94

Утечка мастер-ключа через xpub

Из правила k_i = (I_L + k) mod n следует обратное: k = (k_i − I_L) mod n, а I_L для обычной деривации считается по публичным данным (c, K, i). Значит:

xpub ветки + один приватный ключ любого её обычного потомка = приватный ключ всей ветки.

k_acc, c_acc = derive(seed, "m/44'/60'/0'/0")   # уровень, чей xpub «безобидно» отдали
k_leaf, _    = ckd_priv(k_acc, c_acc, 7)        # ключ одного адреса утёк с сервера
I  = hmac.new(c_acc, ser_p(mul(k_acc)) + (7).to_bytes(4, "big"), hashlib.sha512).digest()
assert (k_leaf - int.from_bytes(I[:32], "big")) % N == k_acc   # ключ ветки восстановлен

Именно поэтому уровни purpose, coin_type и account в BIP-44 обязательно hardened: компрометация листовых ключей вместе с xpub аккаунта не поднимается выше границы hardened. И именно поэтому xpub — не «публичные данные»: он раскрывает всю историю адресов ветки (полная деанонимизация) и становится половиной ключа при малейшей утечке снизу.

5. BIP-44 и пути: почему «мои деньги пропали при импорте»

BIP-44 фиксирует структуру пути m / purpose' / coin_type' / account' / change / address_index; для первого адреса Ethereum это m/44'/60'/0'/0/0. coin_type берётся из SLIP-44: 0 — Bitcoin, 60 — Ethereum, 501 — Solana (EVM-сети на практике почти всегда используют 60). В Bitcoin purpose кодирует тип адреса: 44’ — legacy, 49’ — nested SegWit, 84’ — native SegWit, 86’ — Taproot; из-за этого один и тот же seed в разных кошельках показывает разные адреса и «пустой баланс».

Кошелёк / режим Путь Комментарий
MetaMask, Rabby и большинство софтверных m/44'/60'/0'/0/i новые адреса меняют последний индекс
Ledger Live m/44'/60'/i'/0/0 новый адрес = новый account, hardened
Ledger «legacy» / старый MEW m/44'/60'/0'/i путь длиной 4, без уровня change
Dev-ноды (Hardhat, Anvil) m/44'/60'/0'/0/i публичная мнемоника, средств быть не должно

Сценарий, повторяющийся годами: человек держал средства в Ledger Live, импортировал ту же фразу в MetaMask и увидел нули. Средства на месте — MetaMask открыл m/44'/60'/0'/0/0, а деньги лежат на m/44'/60'/1'/0/0. Лечится выбором пути, а не паникой:

# какой адрес даёт фраза на конкретном пути — считается офлайн, наружу ничего не уходит
cast wallet address --mnemonic "$M" --mnemonic-derivation-path "m/44'/60'/0'/0/0"
cast wallet address --mnemonic "$M" --mnemonic-derivation-path "m/44'/60'/1'/0/0"
cast wallet new-mnemonic --words 24     # новая фраза локально, 256 бит энтропии

Записывайте путь вместе с фразой. Одна строка экономит дни паники при восстановлении через несколько лет, когда кошелька-производителя может уже не существовать.

6. Keystore V3: ключ на диске под паролем

Софтверные кошельки и ноды хранят ключ в JSON-формате Web3 Secret Storage: kdf (scrypt или PBKDF2) с параметрами и солью, cipher: aes-128-ctr с iv, ciphertext и mac. Пароль прогоняется через KDF в 32 байта: первые 16 — AES-ключ, вторые 16 — материал для MAC. mac = keccak256(dk[16:32] ‖ ciphertext) проверяется до расшифровки — отсюда честный ответ «неверный пароль» вместо мусора на выходе.

def unlock_keystore(path: str, password: str) -> bytes:
    ks = json.load(open(path))["crypto"]; p = ks["kdfparams"]
    salt = bytes.fromhex(p["salt"])
    if ks["kdf"] == "scrypt":     # N=262144, r=8 → ~256 МиБ: защита от GPU/ASIC
        dk = hashlib.scrypt(password.encode(), salt=salt, n=p["n"], r=p["r"],
                            p=p["p"], dklen=p["dklen"], maxmem=2 * 1024**3)
    else:
        dk = hashlib.pbkdf2_hmac("sha256", password.encode(), salt, p["c"], p["dklen"])
    ct = bytes.fromhex(ks["ciphertext"])
    h = keccak.new(digest_bits=256); h.update(dk[16:32] + ct)
    if h.hexdigest() != ks["mac"]: raise ValueError("неверный пароль")
    iv = int.from_bytes(bytes.fromhex(ks["cipherparams"]["iv"]), "big")
    return AES.new(dk[:16], AES.MODE_CTR, initial_value=iv, nonce=b"").decrypt(ct)

Память scrypt — примерно 128·N·r, то есть 256 МиБ при стандартных параметрах, и заметная доля секунды на разблокировку: пользователь ждёт полсекунды, атакующий с тысячами GPU упирается в память, а не в вычисления. Значение n: 8192, которое ставят «чтобы быстрее», ослабляет защиту в 32 раза — так делают dev-ноды, и такие файлы нельзя переносить в прод. Для бэкенда-подписанта: ключ в keystore или HSM, пароль в секрет-менеджере. Приватный ключ в .env, попавший в git, находят автоматические сканеры за минуты — боты опустошают такие адреса быстрее, чем автор успевает сделать git push --force.

7. Что именно подписывает кошелёк

Подпись ставится не «на транзакцию», а на keccak-хеш строго определённого байтового представления. В Ethereum сосуществуют несколько типов транзакций (конверт задан EIP-2718):

Тип Что это Подписываемый payload
0x00 legacy старый формат rlp([nonce, gasPrice, gasLimit, to, value, data, chainId, 0, 0]) (EIP-155)
0x02 EIP-1559 базовая комиссия + чаевые 0x02 ‖ rlp([chainId, nonce, maxPriorityFee, maxFee, gas, to, value, data, accessList])
0x03 EIP-4844 блобы для роллапов (Масштабирование) плюс maxFeePerBlobGas и хеши блобов
0x04 EIP-7702 делегирование кода на EOA плюс authorizationList

Сборка preimage вручную — лучший способ понять, что подписывается:

def _len(n, off):                               # префикс длины: короткая/длинная форма
    if n < 56: return bytes([off + n])
    b = n.to_bytes((n.bit_length() + 7) // 8, "big")
    return bytes([off + 55 + len(b)]) + b

def rlp(x):
    """Минимальный RLP-энкодер: целые → big-endian без ведущих нулей."""
    if isinstance(x, int):
        x = b"" if x == 0 else x.to_bytes((x.bit_length() + 7) // 8, "big")
    if isinstance(x, bytes):
        return x if len(x) == 1 and x[0] < 0x80 else _len(len(x), 0x80) + x
    body = b"".join(rlp(i) for i in x)
    return _len(len(body), 0xC0) + body

tx = [1, 42, 2 * 10**9, 30 * 10**9, 21000,      # chainId, nonce, tip, maxFee, gas
      bytes.fromhex("9858effd232b4033e47d90003d41ec34ecaeda94"),
      10**18, b"", []]                          # value 1 ETH, data, accessList
preimage = b"\x02" + rlp(tx)
# sighash = keccak256(preimage); в транзакцию идут yParity, r, s

Три поля здесь — защита, а не бухгалтерия. chainId (EIP-155) не даёт повторить подпись в другой сети; до 2016 года его не было, и после форка Ethereum Classic транзакции проигрывались в обеих сетях. nonce делает транзакцию одноразовой и задаёт строгий порядок. gas ограничивает ущерб от бесконечного цикла в вызываемом контракте.

Два момента ломают интуицию новичков. Во-первых, «подключить кошелёк» ≠ «дать доступ к деньгам»: eth_requestAccounts раскрывает адрес и позволяет сайту предлагать транзакции, списать что-либо без вашей подписи он не может; опасность не в подключении, а в том, что вы подпишете дальше. Во-вторых, устройство подтверждает то, что показывает свой экран, а не то, что нарисовано в браузере: заражённый хост не подделает содержимое экрана устройства — если пользователь его прочитал.

Про nonce стоит знать три следствия его последовательности. Транзакция, зависшая в мемпуле из-за низкой комиссии, отменяется только заменой: та же nonce, комиссия выше минимум на 10% (правило замены у большинства клиентов), а «отмена» — это перевод самому себе нуля с тем же nonce. Дыра в нумерации (отправили 44-ю мимо кошелька) блокирует всё, пока не заполнится. Один ключ в нескольких процессах даёт гонку и отказы nonce too low — в проде делают единый сервис-подписант с сериализацией по адресу или пул адресов (Архитектура dApp).

8. Слепая подпись — главная дыра современного UX

Аппаратное устройство защищает ключ, а не решение. Если на его экране вместо смысла операции показан хеш, пользователь подтверждает неизвестное — это blind signing. Форматы против этого разобраны в Криптографии Web3: EIP-191 для человекочитаемых строк и EIP-712 для типизированных структур, где кошелёк показывает «Permit: spender 0x…, value …, deadline …». Черновик ERC-7730 идёт дальше: описывает метаданные clear signing, чтобы устройство разбирало calldata и показывало смысл вызова, а не 4 байта селектора.

Насколько это серьёзно, показал взлом Bybit в феврале 2025 года — крупнейшая кража в истории отрасли, около 1,46 млрд USD. Протокол не ломали, ключи не крали, мультиподпись работала как задумано: атакующие скомпрометировали инфраструктуру фронтенда Safe{Wallet} и подменили то, что видели подписанты. Они подтверждали безобидную на вид операцию, а подписывали delegatecall, подменяющий реализацию кошелька; все подписи были валидны. Урок формулируется жёстко: мультиподпись защищает от одного нечестного подписанта, но не от того, что все подписанты видят одну и ту же ложь. Защита — независимая проверка хеша операции на отдельном устройстве или машине.

Отсюда правила подписи офчейн-сообщений: запрос eth_sign (подпись произвольного 32-байтного хеша) — красный флаг, MetaMask убрал его именно потому, что под видом «строки для входа» можно подсунуть хеш транзакции; в подписанных данных обязаны быть chainId, адрес контракта-верификатора, nonce и deadline, иначе подпись работает бессрочно и везде; approve(spender, type(uint256).max) и setApprovalForAll — не формальность, а выдача права распоряжаться токенами. Разрешения нужно ревизовать: revoke.cash, Token Approval Checker на Etherscan или напрямую:

cast call $TOKEN "allowance(address,address)(uint256)" $ME $SPENDER --rpc-url $RPC
cast send $TOKEN "approve(address,uint256)" $SPENDER 0 --rpc-url $RPC --ledger

9. Аппаратные кошельки: что они реально дают

Аппаратный кошелёк — не «сейф», а устройство с ограниченным интерфейсом: ключ рождается внутри, наружу выходят только подписи, подтверждение требует физического нажатия. Модель угроз меняется точечно.

Угроза Софтверный кошелёк Аппаратный Мультиподпись 2-из-3
Малварь-стилер на ПК ключ украден защищает защищает
Фишинговый сайт, вы подписываете перевод не защищает не защищает защищает: второй подписант заметит
Слепая подпись сложного вызова не защищает почти не защищает не защищает, если все видят одно и то же
Кража устройства PIN и secure element, но не бесконечно защищает
Утечка seed (фото, облако, ввод на сайте) не защищает не защищает защищает при независимых ключах
Недоступность владельца не защищает не защищает защищает при разнесённых хранителях

Внутри устройств два подхода: чип Secure Element с сертификацией EAL5+/EAL6+ (Ledger, Trezor Safe) против обычного микроконтроллера с полностью открытой прошивкой (Trezor One и Model T). Открытость проверяема, но уязвима к физическим атакам — Kraken Security Labs в 2020 году извлекала seed из Trezor вольтовым глитчингом за пятнадцать минут при физическом доступе, отсюда обязательность passphrase, которой в чипе нет. Secure Element закрыт, зато устойчив к таким атакам, и вопрос доверия смещается к производителю; общая логика изоляции разобрана в Безопасности и изоляции ОС.

Чего аппаратный кошелёк не закрывает: цепочку поставок (в декабре 2023 года скомпрометировали npm-пакет Ledger Connect Kit, дрейнер разошёлся по всем dApp, подключавшим библиотеку с CDN — устройства были целы, скомпрометирован код, решавший, что им отправить на подпись); устройство «с рук» (подделки и предзаполненные seed-карточки — стабильный жанр; настоящее устройство никогда не приходит с готовой фразой, она генерируется при первой инициализации); слепую подпись и seed, введённый на сайте «для проверки совместимости».

10. Смарт-контрактные кошельки: ключ перестаёт быть точкой отказа

Внешний аккаунт (EOA) жёстко связан с одним ключом: нельзя ни поменять ключ, ни задать правила, ни восстановить доступ. Смарт-контрактный кошелёк переносит авторизацию в контракт: несколько владельцев, лимиты, задержки, восстановление через доверенных лиц, сессионные ключи. Два способа сегодня:

  • ERC-4337 (account abstraction). Отдельный мемпул UserOperation, бандлеры собирают их в обычные транзакции, EntryPoint проверяет и исполняет, paymaster может оплатить газ. Кошелёк — контракт, ключ подписи ротируется без смены адреса.
  • EIP-7702 (Pectra, май 2025). Обычный EOA транзакцией типа 0x04 назначает себе делегированный код: батчинг и спонсирование газа при том же адресе. Обратная сторона неприятная: авторизация с chainId = 0 действует во всех сетях, а делегирование на вредоносный контракт равносильно отдаче всех средств — дрейнеры, автоматически подписывающие 7702-делегирование от скомпрометированных ключей, уже наблюдались.

Подписи такие кошельки проверяют через ERC-1271: контракт сам решает, что считать валидной подписью. Типичный модуль восстановления и типичные ошибки в нём:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

/// @notice Восстановление через хранителей. НЕБЕЗОПАСНАЯ версия.
contract BadRecovery {
    address public owner; uint256 public threshold;
    mapping(address => bool) public isGuardian;
    /// ❌ 1: в подписанных данных нет nonce и адреса контракта — подпись
    ///    работает вечно, в любом клоне кошелька и в любой сети.
    /// ❌ 2: результат ecrecover не проверяется на address(0).
    /// ❌ 3: нет защиты от дублей — один хранитель голосует N раз.
    function recover(address newOwner, bytes32[] calldata r,
                     bytes32[] calldata s, uint8[] calldata v) external {
        bytes32 h = keccak256(abi.encodePacked(newOwner));
        uint256 votes;
        for (uint256 i = 0; i < r.length; i++)
            if (isGuardian[ecrecover(h, v[i], r[i], s[i])]) votes++;
        require(votes >= threshold, "not enough guardians");
        owner = newOwner;                       // мгновенно, без окна на отмену
    }
}

Исправленный вариант закрывает всё перечисленное и добавляет то, чего в оригинале нет вообще, — таймлок, дающий настоящему владельцу шанс отменить враждебное восстановление:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.24;

import {ECDSA} from "@openzeppelin/contracts/utils/cryptography/ECDSA.sol";
import {EIP712} from "@openzeppelin/contracts/utils/cryptography/EIP712.sol";

contract GoodRecovery is EIP712 {
    using ECDSA for bytes32;                     // revert вместо address(0)
    bytes32 private constant TYPEHASH =
        keccak256("Recover(address newOwner,uint256 nonce,uint256 deadline)");
    uint256 public constant DELAY = 2 days;      // окно на отмену владельцем
    address public owner; uint256 public threshold; uint256 public nonce;
    mapping(address => bool) public isGuardian;
    address public pendingOwner; uint256 public executeAfter;

    constructor() EIP712("GoodRecovery", "1") {}

    /// Домен EIP-712 включает chainId и address(this): подпись не переносится
    /// ни в другую сеть, ни в другой кошелёк.
    function proposeRecovery(address newOwner, uint256 deadline,
                             bytes[] calldata sigs) external {
        require(block.timestamp <= deadline, "expired");
        require(sigs.length >= threshold, "not enough guardians");
        bytes32 digest = _hashTypedDataV4(
            keccak256(abi.encode(TYPEHASH, newOwner, nonce, deadline)));
        address last;                            // адреса строго по возрастанию
        for (uint256 i = 0; i < sigs.length; i++) {
            address g = digest.recover(sigs[i]); // проверит s <= n/2 и v ∈ {27,28}
            require(g > last && isGuardian[g], "dup / unsorted / not guardian");
            last = g;
        }
        nonce++;                                 // старые подписи мертвы
        pendingOwner = newOwner; executeAfter = block.timestamp + DELAY;
    }

    function cancelRecovery() external {         // единственная привилегия владельца
        require(msg.sender == owner, "only owner");
        pendingOwner = address(0); executeAfter = 0;
    }

    function executeRecovery() external {
        require(executeAfter != 0 && block.timestamp >= executeAfter, "too early");
        owner = pendingOwner; pendingOwner = address(0); executeAfter = 0;
    }
}

Компромисс честный: вы меняете риск «потерял бумажку» на риск «в контракте баг» (Безопасность контрактов) и добавляете зависимость от бандлеров и инфраструктуры. Для больших сумм на практике до сих пор чаще выбирают аудированную мультиподпись вроде Safe: логика проще и проверена годами.

11. Потеря доступа: что восстановимо, а что нет

Первое при потере доступа — точно определить, что именно потеряно: разные потери имеют принципиально разную обратимость.

Как это выглядит в реальности. James Howells выбросил жёсткий диск с ключами от 8000 BTC в 2013 году; совет Ньюпорта раскопки не разрешил, суд в январе 2025 года иск отклонил — диск физически существует, средства тоже. Stefan Thomas держит 7002 BTC на зашифрованном IronKey: из десяти попыток пароля осталось две, после десятой чип стирает ключ — это не метафора «сложно», а буквально if (attempts > 10) wipe(). QuadrigaCX: единственный человек с доступом к холодным кошелькам биржи умер в 2018 году (позже выяснилось, что там была ещё и схема Понци, но исходная проблема — отсутствие процедуры на случай смерти оператора). Parity Multisig (ноябрь 2017): пользователь случайно вызвал selfdestruct в библиотеке, от которой зависели сотни кошельков, и 513 774 ETH заморожены навсегда — ключи целы, код нежизнеспособен.

Что из этого следует для схемы хранения:

  • Одна копия — это ноль копий. Пожар, потоп, переезд, «уборка» — типовые причины потерь; минимум две географически разнесённые копии. Бумага деградирует, металл нет: для сумм, которые вы не готовы потерять, seed выбивают на стальной пластине, потому что пожар в квартире куда вероятнее взлома криптографии.
  • Мнемоника в облаке или на фото — это компрометация, а не бэкап. Облачные галереи распознают текст на изображениях, и утечка аккаунта Google превращается в утечку всех активов.
  • Не «делите фразу на три части по 8 слов». Это не разделение секрета: каждый фрагмент резко сокращает перебор для остальных, а потеря одного убивает всё. Настоящее разделение — SLIP-39 (схема Шамира, свой словарь на 1024 слова, порог m-из-n) или мультиподпись с независимыми ключами. Она же закрывает наследование: доли у разных доверенных лиц решают задачу, которую seed в голове не решает в принципе.

Отдельно: никакие «сервисы восстановления кошельков» не работают — всё, что они могут, это попросить вашу фразу. Восстановление возможно только там, где сохранился хотя бы один секрет: неполная фраза (одно-два неизвестных слова перебираются локально с проверкой контрольной суммы), забытый пароль keystore с известной структурой (btcrecover, hashcat — офлайн, на своём железе), неверный путь деривации. Всё остальное математически безнадёжно.

12. Атаки на пользователя: что реально работает против людей

Контракты ломают редко и громко, людей — постоянно и тихо.

  • Фишинг seed. Поддельный сайт кошелька, «синхронизация», «валидация ноды», «восстановление после обновления». Правило без исключений: seed-фразу не спрашивает никто и никогда — ни поддержка, ни разработчики, ни устройство через сайт.
  • Approval-дрейнеры. Вместо кражи ключа сайт просит подписать approve, Permit или setApprovalForAll — и выводит токены в удобный момент, хоть через месяцы. В окне кошелька это выглядит скромнее, чем перевод.
  • Address poisoning. Атакующий шлёт вам транзакцию на 0 токенов с адреса, у которого совпадают первые и последние 4 символа; вы копируете адрес из истории — деньги уходят двойнику. В мае 2024 года так потеряли 1155 WBTC (около 68 млн USD). Защита: адресная книга и сверка целиком, а не «первые четыре символа».
  • Clipboard hijacker подменяет адрес в буфере обмена — сверяйте адрес на экране устройства, а не в браузере. Рядом стоят фальшивые приложения с похожими названиями, фейковые эйрдропы, «поддержка» в личке и поддельные вакансии с «тестовым заданием» в виде npm-пакета, читающего файлы кошелька.

13. Операционная гигиена: что делать руками

Разделение по назначению работает лучше любых отдельных мер:

Уровень Назначение Инструмент Практика
Одноразовый тест непроверенного dApp свежий адрес, минимальный баланс не жалко потерять
Горячий ежедневные операции расширение или мобильный кошелёк лимит суммы, регулярный revoke
Тёплый периодические операции аппаратный кошелёк подпись только с проверкой на экране
Холодный долгое хранение аппаратный + passphrase, отдельный seed подключается редко, не «ходит по сайтам»
Командный общие средства, казна Safe m-из-n подписанты на разных устройствах и в разных местах
Генерация  [ ] энтропия от CSPRNG или самого устройства, не от «генератора в вебе»
           [ ] мнемоника на носителе, устойчивом к воде и огню; записан путь деривации
           [ ] восстановление проверено на чистом устройстве до внесения суммы
Хранение   [ ] две копии в разных физических местах, passphrase хранится отдельно
           [ ] нет ни одной цифровой копии: ни фото, ни облака, ни заметок
           [ ] есть план на случай смерти или недоступности владельца
Операции   [ ] адрес получателя сверяется целиком на экране устройства
           [ ] approve на нужную сумму, а не на бесконечность; регулярный revoke
           [ ] непонятная подпись = отказ, а не «наверное, так надо»
Разработка [ ] ключи не в git, не в .env рядом с кодом, не в логах
           [ ] тестовая мнемоника «test test … junk» — никогда в мейннете
           [ ] прод-подписант — HSM/KMS или keystore с сильным scrypt
           [ ] один адрес обслуживается одним процессом, иначе гонка за nonce

Мини-итог

  • Кошелёк хранит не монеты, а право подписи; баланс живёт в состоянии сети и публичен. Стойкость определяется энтропией на первом шаге: Profanity и Milk Sad — это не «слабое шифрование», а 32-битное зерно.
  • BIP-39 кодирует энтропию в слова и добавляет контрольную сумму на ENT/32 бит; мнемоника — не ключ и не пароль, сменить её нельзя. Passphrase не усиливает seed, а создаёт другой кошелёк, и её потеря так же необратима.
  • BIP-32 строит дерево из одного seed; hardened-уровни существуют потому, что xpub плюс один утёкший дочерний ключ выдают приватный ключ всей ветки.
  • Путь деривации — часть бэкапа: «пропавшие» после импорта средства почти всегда лежат на другом пути. Подписывается keccak-хеш строго определённого RLP-представления, где chainId, nonce и deadline — защита от повтора, а не бюрократия.
  • Аппаратный кошелёк защищает ключ, но не решение: слепая подпись обесценивает его, что и показал взлом Bybit на 1,46 млрд USD при полностью корректной мультиподписи.
  • Смарт-кошельки (ERC-4337, EIP-7702) дают восстановление, лимиты и ротацию ключей — ценой доверия к коду и инфраструктуре.
  • Потеря доступа необратима технически, а не бюрократически: сервисов восстановления не существует, работают только заранее сделанные копии и разделение секрета.

Источники

Что дальше

Мы разобрали, как человек управляет ключом и как этот ключ подписывает изменение состояния. Дальше — про то, что чаще всего этим ключом двигают: про токены как контракты, а не «монеты внутри кошелька», и про то, почему transfer у ERC-20 и safeTransferFrom у ERC-721 устроены именно так. Читайте Токены и стандарты: ERC-20, ERC-721, ERC-1155 и что за ними стоит.

Нашли неточность? Выделите фрагмент текста — рядом появится жучок.

Нужен разбор именно вашей ситуации?

Статья описывает общий случай. Если у вас частный — можно разобрать его отдельно, платно. А если не хватает целого материала, предложите тему: её оплачивают вскладчину, и она выходит открытой для всех.

Доска запросов