Кошельки и ключи: seed-фразы, HD-кошельки, подпись транзакций, потеря доступа
В Криптографии Web3 мы выяснили главное: приватный ключ — это и есть аккаунт. Нет логина, нет пароля, нет службы поддержки. Кошелёк — программа, которая помогает человеку управлять числом d длиной 32 байта так, чтобы его не потерять и не отдать посторонним. Всё остальное в интерфейсе — балансы, история, названия токенов — читается из блокчейна публично и само по себе ценности не имеет.
Эта статья — про инженерное устройство слоя между человеком и d. Здесь больше всего мифов («seed — это мой пароль», «аппаратный кошелёк нельзя взломать», «я подключил кошелёк к сайту, значит меня уже могут обокрасть») и здесь же происходит подавляющее большинство реальных потерь. По оценке Chainalysis, около 20% всех существующих биткоинов лежат на адресах, доступ к которым утрачен навсегда: это следствие не взломов протокола, а того, как люди обращаются с ключами. Сразу о жанре: дальше нет ни слова о том, что покупать и куда пойдёт цена — только механика, форматы данных и компромиссы.
1. Кошелёк не хранит монеты — он хранит право подписи
Токенов «внутри кошелька» не существует. Баланс — запись в состоянии сети, привязанная к адресу (Ethereum и EVM). Кошелёк хранит секрет, позволяющий эту запись изменить, и умеет три вещи: породить или восстановить ключи, показать состояние, собрать и подписать сообщение.
| Что | Где физически лежит | При утечке | При потере |
|---|---|---|---|
| Мнемоника (12–24 слова) / seed | бумага, металл, secure element, (плохо) облако | полный контроль над всем деревом ключей | доступ утрачен, если нет отдельных копий ключей |
| Приватный ключ адреса | RAM, keystore-файл, чип | контроль над одним адресом | недоступен один адрес |
| Пароль от keystore | голова, менеджер паролей | нужен ещё и файл | файл бесполезен |
| passphrase («25-е слово») | голова, отдельный носитель | нужна ещё и мнемоника | доступ утрачен полностью |
Дерево «энтропия → адреса» выглядит так, и каждая стрелка здесь односторонняя:
→ 12–24 слова"] B --> C["PBKDF2-HMAC-SHA512, 2048 итераций
соль = 'mnemonic' + passphrase"] P["passphrase
(необязательна)"] -.-> C C --> D["seed, 512 бит"] D --> E["BIP-32: HMAC-SHA512('Bitcoin seed', seed)
→ мастер-ключ k + chain code c"] E --> F["BIP-44: путь m/44'/60'/0'/0/i"] F --> G["приватный ключ адреса d"] G --> H["Q = d·G → адрес = 20 байт keccak256(Q)"] G --> J["подпись ECDSA → broadcast через RPC"] style A fill:#d9534a22,stroke:#d9534a style D fill:#3fa66a22,stroke:#3fa66a style H fill:#4a90d922,stroke:#4a90d9
Ключевое наблюдение: шаги 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.
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 порождает бесконечное дерево, бэкап делается один раз.
Узел дерева — пара «приватный ключ 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) дают восстановление, лимиты и ротацию ключей — ценой доверия к коду и инфраструктуре.
- Потеря доступа необратима технически, а не бюрократически: сервисов восстановления не существует, работают только заранее сделанные копии и разделение секрета.
Источники
- BIP-32 (HD-кошельки), BIP-39 (мнемоника), BIP-44 (пути): github.com/bitcoin/bips
- SLIP-39 (схема Шамира) и SLIP-44 (coin types): github.com/satoshilabs/slips
- Andreas Antonopoulos, Gavin Wood. Mastering Ethereum, глава Wallets: github.com/ethereumbook/ethereumbook
- Web3 Secret Storage Definition (keystore V3): ethereum.org/en/developers/docs/data-structures-and-encoding/web3-secret-storage/
- EIP-155, EIP-2718, EIP-1559, EIP-4337, EIP-7702, ERC-1271: eips.ethereum.org
- Уязвимость Profanity: blog.1inch.io/a-vulnerability-disclosed-in-profanity/; Milk Sad, CVE-2023-39910: milksad.info
- Kraken Security Labs о физической атаке на Trezor: blog.kraken.com/product/security/kraken-identifies-critical-flaw-in-trezor-hardware-wallets
- Chainalysis о доле утраченных биткоинов: chainalysis.com/blog/; хронология инцидентов: rekt.news/leaderboard
- Foundry Book,
cast wallet: book.getfoundry.sh/reference/cast/cast-wallet
Что дальше
Мы разобрали, как человек управляет ключом и как этот ключ подписывает изменение состояния. Дальше — про то, что чаще всего этим ключом двигают: про токены как контракты, а не «монеты внутри кошелька», и про то, почему transfer у ERC-20 и safeTransferFrom у ERC-721 устроены именно так. Читайте Токены и стандарты: ERC-20, ERC-721, ERC-1155 и что за ними стоит.