Коротко. Форматов два. Устаревший, описанный задним числом как BIP-137, — это 65-байтная подпись ECDSA с восстановлением ключа, которая покрывает "\x18Bitcoin Signed Message:\n" ‖ length ‖ message; она доказывает только контроль над ключом, а её первый байт (от 27 до 42) сообщает, какой тип адреса имел в виду подписант. Bitcoin Core создаёт такую подпись только для устаревших адресов P2PKH, а для адресов SegWit кошельки заполняют заголовок по-разному. BIP-322 (статус Complete) доказывает контроль над адресом: он строит виртуальную транзакцию, которая тратит средства с этого адреса, а подписью служит witness этой траты, поэтому формат работает для P2WPKH, P2TR, мультиподписи и скриптов. С версии 2.0.0 этого BIP у подписей есть префикс: smp (простая), ful (полная) или pof (доказательство наличия средств). Проверяйте подписи библиотекой, которая понимает префикс, и принимайте простые подписи без префикса от старых кошельков.
Устаревший формат (BIP-137)
Дайджест — двойной SHA-256 от фиксированного префикса, длины сообщения в формате varint Bitcoin и байтов сообщения. Подпись имеет вид header ‖ r ‖ s, занимает 65 байт и обычно кодируется в base64. Заголовок содержит идентификатор восстановления (от 0 до 3), по которому проверяющая сторона вычисляет открытый ключ из подписи, и константу, обозначающую тип ключа и адреса (BIP-137, статус Deployed):
| Заголовок | Значение |
|---|---|
| 27–30 | P2PKH, несжатый открытый ключ |
| 31–34 | P2PKH, сжатый открытый ключ |
| 35–38 | P2SH-P2WPKH (вложенный SegWit) |
| 39–42 | P2WPKH (нативный SegWit, bc1q…) |
При проверке из подписи восстанавливают открытый ключ, выводят из него адрес того типа, который указан в заголовке, и сравнивают с заявленным. Ни транзакции, ни скрипта здесь нет: формат не может выразить «этот адрес с мультиподписью» или «этот адрес Taproot», только «владелец этого ключа». Для всего, что выходит за эти рамки, BIP-137 сам отсылает к BIP-322.
Модуль ниже реализует устаревший формат, а дальше в том же файле — простые подписи BIP-322 для P2WPKH; в нём используются @noble/curves и @scure/base:
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { ripemd160 } from "@noble/hashes/legacy.js";
import { sha256 } from "@noble/hashes/sha2.js";
import { bech32, bech32m, createBase58check } from "@scure/base";
const b58 = createBase58check(sha256);
const sha256d = (b) => sha256(sha256(Uint8Array.from(b)));
const hash160 = (b) => ripemd160(sha256(b));
const utf8 = (s) => new TextEncoder().encode(s);
const cat = (...parts) => Uint8Array.from(parts.flatMap((p) => [...p]));
const varint = (n) => (n < 0xfd ? [n] : [0xfd, n & 0xff, n >> 8]); // messages under 64 KiB
export const NETWORKS = {
mainnet: { p2pkh: 0x00, p2sh: 0x05, hrp: "bc" },
regtest: { p2pkh: 0x6f, p2sh: 0xc4, hrp: "bcrt" },
};
/** Addresses of one compressed public key. */
export function addressesOf(pubkey, net = NETWORKS.mainnet) {
const h = hash160(pubkey);
const redeem = cat([0x00, 0x14], h); // P2SH-P2WPKH redeem script
return {
p2pkh: b58.encode(cat([net.p2pkh], h)),
"p2sh-p2wpkh": b58.encode(cat([net.p2sh], hash160(redeem))),
p2wpkh: bech32.encode(net.hrp, [0, ...bech32.toWords(h)]),
};
}
/** scriptPubKey of an address, for BIP-322's message_challenge. */
export function scriptPubKey(address, net = NETWORKS.mainnet) {
if (address.toLowerCase().startsWith(`${net.hrp}1`)) {
const isV0 = address.toLowerCase().startsWith(`${net.hrp}1q`);
const { words } = (isV0 ? bech32 : bech32m).decode(address);
const program = (isV0 ? bech32 : bech32m).fromWords(words.slice(1));
return cat([words[0] === 0 ? 0x00 : 0x50 + words[0], program.length], program);
}
const raw = b58.decode(address);
if (raw[0] === net.p2pkh) return cat([0x76, 0xa9, 0x14], raw.slice(1), [0x88, 0xac]);
if (raw[0] === net.p2sh) return cat([0xa9, 0x14], raw.slice(1), [0x87]);
throw new Error(`unsupported address ${address}`);
}
// ---------------------------------------------------------------------------- BIP-137 (legacy)
/** The digest every legacy message signature covers. */
export const legacyDigest = (message) => {
const m = utf8(message);
return sha256d(cat([0x18], utf8("Bitcoin Signed Message:\n"), varint(m.length), m));
};
const HEADER_BASE = { p2pkh: 31, "p2sh-p2wpkh": 35, p2wpkh: 39 }; // compressed keys
/** 65-byte compact signature, base64. headerFor "electrum" keeps 31-34 for every address type. */
export function signLegacy(message, privateKey, type = "p2pkh", headerFor = "bip137") {
const sig = secp256k1.Signature.fromBytes(secp256k1.sign(legacyDigest(message), privateKey, { prehash: false, format: "recovered" }), "recovered");
const base = headerFor === "electrum" ? 31 : HEADER_BASE[type];
return Buffer.from(cat([base + sig.recovery], sig.toBytes("compact"))).toString("base64");
}
/** Recovers the key and checks it against the address; strict mode also checks the header type. */
export function verifyLegacy(address, message, signatureBase64, { net = NETWORKS.mainnet, strict = false } = {}) {
const raw = Buffer.from(signatureBase64, "base64");
if (raw.length !== 65) throw new Error(`expected 65 bytes, got ${raw.length}`);
const header = raw[0];
if (header < 27 || header > 42) throw new Error(`header byte ${header} out of range`);
const recovery = (header - 27) % 4;
const typeFromHeader = header >= 39 ? "p2wpkh" : header >= 35 ? "p2sh-p2wpkh" : "p2pkh";
const compressed = header >= 31;
const sig = secp256k1.Signature.fromBytes(raw.subarray(1), "compact").addRecoveryBit(recovery);
const pubkey = sig.recoverPublicKey(legacyDigest(message)).toBytes(compressed);
const candidates = addressesOf(pubkey, net);
const match = Object.entries(candidates).find(([, a]) => a === address)?.[0];
if (!match) return { valid: false };
if (strict && match !== typeFromHeader) return { valid: false, reason: `header says ${typeFromHeader}, address is ${match}` };
return { valid: true, type: match, header };
}
// ---------------------------------------------------------------------------- BIP-322 (simple, P2WPKH)
const tag = sha256(utf8("BIP0322-signed-message"));
/** sha256(tag || tag || m), a BIP-340 tagged hash of the raw message. */
export const messageHash = (message) => sha256(cat(tag, tag, utf8(message)));
const le32 = (n) => [n & 0xff, (n >>> 8) & 0xff, (n >>> 16) & 0xff, n >>> 24];
const zero64 = new Array(8).fill(0);
/** The two virtual transactions of BIP-322, serialized without witness. Returns txids too. */
export function virtualTransactions(address, message, net = NETWORKS.mainnet) {
const challenge = scriptPubKey(address, net);
const toSpend = cat(
le32(0), [1], new Array(32).fill(0), le32(0xffffffff), // version 0, one input spending 000…000:FFFFFFFF
[34, 0x00, 0x20], messageHash(message), le32(0), // scriptSig OP_0 PUSH32[message_hash], nSequence 0
[1], zero64, [challenge.length], challenge, le32(0), // one output, value 0, scriptPubKey = challenge
);
const toSpendId = sha256d(toSpend); // internal byte order
const toSign = cat(le32(0), [1], toSpendId, le32(0), [0], le32(0), [1], zero64, [1, 0x6a], le32(0));
const display = (h) => Buffer.from(h).reverse().toString("hex");
return { challenge, toSpendId, toSpendTxid: display(toSpendId), toSignTxid: display(sha256d(toSign)) };
}
/** Verifies a BIP-322 simple signature for a P2WPKH address. Accepts the "smp" prefix or none. */
export function verifySimpleP2wpkh(address, message, signature, net = NETWORKS.mainnet) {
const b64 = signature.startsWith("smp") ? signature.slice(3) : signature;
const w = Buffer.from(b64, "base64");
// Witness stack: count, then <len><DER sig + sighash byte>, <len><33-byte pubkey>.
if (w[0] !== 2) throw new Error("expected a two-item witness stack");
const sigWithType = w.subarray(2, 2 + w[1]);
const pubkey = w.subarray(3 + w[1], 3 + w[1] + w[2 + w[1]]);
const { challenge, toSpendId } = virtualTransactions(address, message, net);
if (challenge[0] !== 0x00 || challenge[1] !== 20) throw new Error("not a P2WPKH address");
if (Buffer.compare(Buffer.from(hash160(pubkey)), Buffer.from(challenge.subarray(2))) !== 0) return false;
if (sigWithType.at(-1) !== 0x01) throw new Error("only SIGHASH_ALL is allowed");
// BIP-143 digest of to_sign input 0, spending to_spend:0 worth 0 sat.
const scriptCode = cat([0x19, 0x76, 0xa9, 0x14], challenge.subarray(2), [0x88, 0xac]);
const outputs = cat(zero64, [1, 0x6a]);
const preimage = cat(
le32(0), sha256d(cat(toSpendId, le32(0))), sha256d(le32(0)), toSpendId, le32(0),
scriptCode, zero64, le32(0), sha256d(outputs), le32(0), le32(1),
);
const der = sigWithType.subarray(0, -1);
return secp256k1.verify(der, sha256d(preimage), pubkey, { prehash: false, format: "der", lowS: true });
}
Что поддерживает Bitcoin Core
RPC-методы signmessage и verifymessage в Bitcoin Core реализуют устаревший формат, и только для устаревших адресов. Узел regtest, по одному адресу каждого типа:
#!/bin/sh
# Bitcoin Core in regtest: which address types can sign and verify messages, and the exact errors.
set -e
DATA=/tmp/regtest
mkdir -p "$DATA"
bitcoind -regtest -datadir="$DATA" -daemonwait -fallbackfee=0.0001 >/dev/null
cli() { bitcoin-cli -regtest -datadir="$DATA" "$@"; }
cli -named createwallet wallet_name=lab >/dev/null
echo "$(bitcoind -version | head -1)"
MSG="ecdsa.com lab, 2026-10-20"
for type in legacy p2sh-segwit bech32 bech32m; do
addr=$(cli getnewaddress "" "$type")
printf '%-12s %s\n' "$type" "$addr"
if sig=$(cli signmessage "$addr" "$MSG" 2>/tmp/err); then
echo " signmessage: $sig"
echo " verifymessage: $(cli verifymessage "$addr" "$sig" "$MSG")"
echo "$addr $sig" > "/w/core-$type.txt"
else
echo " signmessage: $(tr '\n' ' ' < /tmp/err)"
fi
done
# A signature made elsewhere for a segwit address: Core cannot even attempt it.
seg=$(cli getnewaddress "" bech32)
leg=$(cut -d' ' -f2 /w/core-legacy.txt)
echo "verifymessage with a bech32 address: $(cli verifymessage "$seg" "$leg" "$MSG" 2>&1 | tr '\n' ' ')"
cli stop >/dev/null
Bitcoin Core daemon version v31.1.0 bitcoind
legacy mnaZRqP42LwTsVNQGfSb1eeTMvVzkaMoWp
signmessage: ICZ/Fs0I/i7XNJbC+ubYkwpDZXI0uFZU1oaqrSaJlS+hYavTSzdfPKmRkLJrUdi6/RXl9WsYNEW5OU9l9hfMHCI=
verifymessage: true
p2sh-segwit 2MyhmVFuFbTr4kAF1S4TtVmjEgkdouEntKK
signmessage: error code: -3 error message: Address does not refer to key
bech32 bcrt1qulxz0zapcld55a5twc9k88028y6dyxrggxug0n
signmessage: error code: -3 error message: Address does not refer to key
bech32m bcrt1pq8x39vj0qay20825g8m3kffzx7sqwu9lfzmtjejta94luunwkmfsdc0hnd
signmessage: error code: -3 error message: Address does not refer to key
verifymessage with a bech32 address: error code: -3 error message: Address does not refer to key
Bitcoin Core возвращает ошибку Address does not refer to key для любого адреса SegWit и Taproot — и при подписи, и при проверке подписи, которую создал кто-то другой. Первый байт устаревшей подписи, в этих запусках 31 или 32 (H или I в base64), обозначает сжатый ключ P2PKH.
Наша реализация выдаёт те же байты, что и Bitcoin Core. Ключ сгенерировали специально для этого запуска и импортировали в кошелёк regtest как дескриптор pkh(), после чего обе реализации подписали одно и то же сообщение:
address mk1giEaBaAFPXNRMQcZNptPAp9kJmttKyp
core signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
our signature: H0bepOQGQpAS+FqLbBoVqjiXqArVq4AQlD59fhwpAX8bdTwCAlE407C2Ik9hTp5pxcsCv6C3arMw+bGjzYtH4mE=
identical: yes
core verifymessage(our signature): true
Подписи совпадают, потому что обе реализации используют детерминированные nonce (RFC 6979) и нормализацию low-S; подписант со случайными nonce каждый раз получает другую, но столь же верную подпись.
Проблема заголовка для адресов SegWit
Кошельки, которые подписывают сообщения в устаревшем формате для адресов SegWit, расходятся в выборе заголовка. Electrum для адреса любого типа ставит заголовок от 27 до 34, а при проверке перебирает все типы адресов с одним ключом, которые могут соответствовать восстановленному ключу (electrum/bitcoin.py). Trezor следует BIP-137 и прибавляет 4 для P2SH-P2WPKH и 8 для P2WPKH, а параметр no_script_type позволяет вместо этого получить вариант Electrum (trezor-firmware, sign_message.py). Поэтому проверяющей стороне приходится выбирать между строгой проверкой (заголовок должен указывать тип именно этого адреса) и нестрогой (подходит любой адрес восстановленного ключа). Вот оба соглашения на одном и том же ключе и сообщении, проверенные нашим кодом и библиотекой bip322-js 4.0.0:
// Same key, same message, P2WPKH address: the BIP-137 header and the Electrum convention, and who accepts which.
import { secp256k1 } from "@noble/curves/secp256k1.js";
import { Verifier } from "bip322-js";
import { addressesOf, signLegacy, verifyLegacy } from "./btcmsg.mjs";
const priv = secp256k1.utils.randomSecretKey();
const { p2pkh, p2wpkh } = addressesOf(secp256k1.getPublicKey(priv, true));
const message = "ecdsa.com lab, 2026-10-20";
const lib = (address, sig, strict) => {
try {
return Verifier.verifySignature(address, message, sig, strict);
} catch (e) {
return `throws: ${e.message}`;
}
};
for (const [label, sig] of [
["BIP-137 header (39-42)", signLegacy(message, priv, "p2wpkh", "bip137")],
["Electrum header (31-34)", signLegacy(message, priv, "p2wpkh", "electrum")],
]) {
const header = Buffer.from(sig, "base64")[0];
const loose = verifyLegacy(p2wpkh, message, sig);
const strict = verifyLegacy(p2wpkh, message, sig, { strict: true });
console.log(`${label}: header ${header}`);
console.log(` ours, loose: ${loose.valid} ours, strict: ${strict.valid}${strict.reason ? ` (${strict.reason})` : ""}`);
console.log(` bip322-js, loose: ${lib(p2wpkh, sig, false)} bip322-js, strict: ${lib(p2wpkh, sig, true)}`);
console.log(` same signature for the P2PKH address of the key: ours, loose ${verifyLegacy(p2pkh, message, sig).valid}`);
}
BIP-137 header (39-42): header 39
ours, loose: true ours, strict: true
bip322-js, loose: true bip322-js, strict: true
same signature for the P2PKH address of the key: ours, loose true
Electrum header (31-34): header 31
ours, loose: true ours, strict: false (header says p2pkh, address is p2wpkh)
bip322-js, loose: true bip322-js, strict: false
same signature for the P2PKH address of the key: ours, loose true
Нестрогая проверка принимает обе подписи, а заодно принимает ту же подпись как доказательство и для адреса P2PKH этого ключа. Таков честный смысл устаревшей подписи: «владелец этого ключа» — для любого адреса, выведенного из него. Строгая проверка отклоняет любую подпись для SegWit в стиле Electrum. Если вам приходится принимать устаревшие подписи для адресов SegWit, проверяйте их нестрого и фиксируйте, что проверен именно контроль над ключом.
BIP-322: доказательство контроля над адресом
BIP-322 (bip-0322.mediawiki, статус Complete, версия 2.0.0) не изобретает формат сообщений, а использует механизм транзакций. Строятся две виртуальные транзакции:
to_spend: версия 0, один вход, который тратит несуществующий выход (outpoint)000…000:FFFFFFFFсо scriptSigOP_0 PUSH32[message_hash], и один выход на 0 сатоши, в scriptPubKey которого стоит адрес, контроль над которым доказывается.message_hash— тегированный хеш BIP-340,SHA256(SHA256(tag) ‖ SHA256(tag) ‖ message)с тегомBIP0322-signed-message.to_sign: версия 0, один вход, который тратитto_spend:0, и один выход на 0 сатоши вOP_RETURN.
Подписать сообщение — значит потратить to_spend:0 в to_sign так, как кошелёк потратил бы любой выход этого адреса. При проверке эту трату оценивают по стандартным правилам скриптов (SIGHASH_ALL или значение по умолчанию в Taproot, low-S и строгий DER, CLEANSTACK и другие). Ни одна из двух транзакций не действительна в сети, поэтому подпись невозможно отправить в сеть, чтобы переместить средства.
Кодировки:
| Вариант | Префикс | Содержимое | Применение |
|---|---|---|---|
| Простой | smp | стек witness в base64 | нативный SegWit и Taproot без блокировок по времени |
| Полный | ful | вся подписанная транзакция to_sign в base64 | любой скрипт, включая вложенный SegWit, устаревшие адреса и блокировки по времени |
| Полный (доказательство наличия средств) | pof | финализированная PSBT транзакции to_sign с дополнительными входами | доказательство контроля над конкретными UTXO |
| Устаревший | нет | подпись BIP-137 | допускается только для устаревших адресов P2PKH |
Префиксы вариантов появились в версии 2.0.0, и теперь подписанты ДОЛЖНЫ их указывать. Для совместимости «при отсутствии префикса проверяющая сторона может считать вариант простым» — благодаря этому подписи кошельков, написанных по прежней редакции, продолжают проходить проверку. У проверки три исхода, а не два: подпись верна, неверна или результат не определён (inconclusive), если проверяющая сторона не может выполнить задействованные скрипты.
Мультиподпись и аппаратные кошельки: подпись через PSBT
Для адреса с мультиподписью witness должен содержать подписи нескольких ключей, которые хранятся у разных людей или на разных устройствах. BIP-322 решает эту задачу инструментами, которые у пользователей мультиподписи уже есть: транзакцию to_sign передают между участниками как PSBT с новым глобальным полем PSBT_GLOBAL_GENERIC_SIGNED_MESSAGE (тип 0x09), в котором лежит сообщение. Если подписант видит это поле, первый вход, который тратит выход 0 соответствующей транзакции to_spend, и единственный выход OP_RETURN с нулевой суммой, он должен показать пользователю сообщение и адрес, а не транзакцию, а затем подписать так же, как подписал бы трату. Каждый из совместных подписантов добавляет частичную подпись; финализированный результат становится подписью ful. Устройство, которое не знает этого поля, видит нечто похожее на транзакцию, тратящую 0 сатоши, поэтому поддержка этого сценария в кошельках важнее криптографии.
В BIP указаны две эталонные реализации: pull request в Bitcoin Core с базовой поддержкой (#24058), который закрыли без слияния, и pull request в btcd с полной поддержкой (#2521), из которого взяты сгенерированные тестовые векторы и который на 20 октября 2026 года всё ещё открыт. Ни одна из них не вошла в релиз, и отчасти поэтому каждый из тех кошельков и библиотек в таблице ниже, которые поддерживают BIP-322, реализует его самостоятельно.
Проверка на тестовых векторах BIP
В состав BIP-322 входят базовые тестовые векторы: хеши сообщений и идентификаторы транзакций для трёх сообщений, а также простые подписи для адреса P2WPKH. Для их проверки нужны только адрес, сообщение и подпись. Те же подписи, с новым префиксом и без него, мы передали и в bip322-js 4.0.0:
// BIP-322's published test vectors (addresses, messages and signatures only), against btcmsg.mjs and bip322-js 4.0.0.
import { readFileSync } from "node:fs";
import { Verifier } from "bip322-js";
import { messageHash, verifySimpleP2wpkh, virtualTransactions } from "./btcmsg.mjs";
const vectors = JSON.parse(readFileSync("bip322-basic-test-vectors.json", "utf8"));
for (const v of vectors.tx_hashes) {
const { toSpendTxid, toSignTxid } = virtualTransactions(v.address, v.message);
const ok = Buffer.from(messageHash(v.message)).toString("hex") === v.message_hash && toSpendTxid === v.to_spend_tx_hash && toSignTxid === v.to_sign_tx_hash;
console.log(`hashes for ${JSON.stringify(v.message).padEnd(16).slice(0, 16)} message_hash, to_spend, to_sign match: ${ok}`);
}
for (const v of vectors.simple.filter((x) => x.type === "p2wpkh")) {
for (const sig of v.bip322_signatures) {
const ours = verifySimpleP2wpkh(v.address, v.message, sig);
const call = (s) => { try { return Verifier.verifySignature(v.address, v.message, s); } catch (e) { return `throws: ${e.message}`; } };
console.log(`${JSON.stringify(v.message).padEnd(14)} ${sig.slice(0, 12)}... ours: ${ours} | bip322-js: ${call(sig)} | bip322-js, prefix removed: ${call(sig.slice(3))}`);
}
}
const wrong = vectors.error.find((e) => e.description.startsWith("wrong message for valid simple p2wpkh"));
console.log(`error vector "${wrong.description}": ours ${verifySimpleP2wpkh(wrong.address, wrong.message, wrong.signature)}`);
hashes for "" message_hash, to_spend, to_sign match: true
hashes for "Hello World" message_hash, to_spend, to_sign match: true
hashes for "UTF-8 support: message_hash, to_spend, to_sign match: true
"" smpAkcwRAIgM... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"" smpAkgwRQIhA... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World" smpAkcwRAIgZ... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
"Hello World" smpAkgwRQIhA... ours: true | bip322-js: throws: buffer too small | bip322-js, prefix removed: true
error vector "wrong message for valid simple p2wpkh signature (empty message was signed)": ours false
Реализация выше воспроизводит каждый хеш, успешно проверяет каждую простую подпись P2WPKH, с префиксом и без, и отклоняет вектор ошибки. bip322-js 4.0.0, широко используемая библиотека на JavaScript, не понимает префикс smp: она декодирует его как base64 и выбрасывает ошибку buffer too small, хотя та же подпись без префикса проходит проверку. Поэтому подписи, созданные новыми кошельками, не пройдут проверку в сервисах на этой библиотеке, пока её не обновят; для простых подписей рабочий обходной путь — отрезать известный префикс перед её вызовом.
Кто что создаёт и принимает
По исходному коду каждого проекта или, где это отмечено, по тестовому запуску, по состоянию на октябрь 2026 года:
| Программа | Устаревший формат (BIP-137) | BIP-322 | Примечания |
|---|---|---|---|
| Bitcoin Core 31.1 | только P2PKH | нет | проверено выше |
| Electrum (master) | P2PKH, P2WPKH, P2SH-P2WPKH, заголовок 27–34 | нет | проверяет оба соглашения о заголовке; отклоняет адреса, у которых не один ключ |
| Прошивка Trezor (main) | P2PKH, P2SH-P2WPKH, P2WPKH, заголовки BIP-137 | нет | Taproot: «Unsupported script type» |
| Sparrow (drongo master) | да | простые подписи с префиксами smp/ful/pof | проверка: простой вариант, P2WPKH и P2TR с одним ключом |
| bip322-js 4.0.0 | да, нестрогая или строгая проверка | простые подписи: P2WPKH, P2SH-P2WPKH, P2TR с одним ключом | без префиксов; см. примечание выше |
Практические выводы: контроль над адресом Taproot можно доказать только через BIP-322; для адреса с мультиподписью устаревший формат не подходит вообще; а сервису, который хочет принимать доказательства от пользователей всех этих кошельков, нужно принимать устаревшие подписи (для SegWit — с нестрогой проверкой), простые подписи BIP-322 без префикса и подписи с префиксом.
Как проверять подписи в сервисе
- Задавайте сообщение сами. Включите в него свой домен, nonce или метку времени и назначение (
"Proof of ownership for example.com, nonce 7f3a…") и принимайте каждый nonce только один раз — точно так же, как в Sign-In with Ethereum. Подпись под типовым текстом можно повторно предъявить любому другому сервису, который просит подписать тот же текст. - Сравнивайте байты точно. Оба формата подписывают сообщение как есть. Интерфейс Electrum перед подписью удаляет пробельные символы в начале и в конце; лишний перевод строки в конце после копирования и вставки или окончания строк CRLF дают другой хеш, и подпись оказывается «неверной».
- Определяйте формат в таком порядке: префикс
smp,fulилиpof; строка base64, которая декодируется ровно в 65 байт с первым байтом от 27 до 42 (устаревший формат); в остальных случаях пробуйте простую подпись BIP-322 без префикса. - Решите, что доказывает устаревшая подпись для адресов SegWit: нестрогая проверка доказывает контроль над ключом, а для адресов с одним ключом это и есть контроль над адресом.
- Считайте неопределённый результат непроверенным. Проверяющая сторона, которая не может выполнить скрипт (блокировки по времени, необычные скрипты), не должна сообщать об успехе.
- Для доказательства наличия средств сверяйтесь с блокчейном. Подпись
pofдоказывает контроль над перечисленными выходами на момент подписи; не потрачены ли они с тех пор, нужно проверять отдельно, и такая подпись никогда не доказывает, что это все выходы адреса.
Источники
- BIP-137: подписи сообщений закрытыми ключами (Signatures of Messages using Private Keys)
- BIP-322: универсальный формат подписанных сообщений (Generic Signed Message Format), версия 2.0.0, и его базовые тестовые векторы
- BIP-340 (тегированные хеши), BIP-143 (хеш подписи SegWit), BIP-174 (PSBT)
- RFC 6979: детерминированные DSA и ECDSA (Deterministic DSA and ECDSA)
- Bitcoin Core 31.1, RPC-методы
signmessageиverifymessage - Electrum
bitcoin.py, Trezorsign_message.py, Sparrow drongoBip322.java, bip322-js