Криптография и подпись команд
Подпись в hlf-core — не часть Fabric, а уровень приложения: Fabric подтверждает, что транзакция
пришла от узла организации, а hlf-core отвечает на вопрос «кто именно её совершил». Поэтому вся
криптография берётся из @ts-core/common и его крипто-пакетов и остаётся одинаковой, подписываем
мы команду для блокчейна или для обычного сервиса — chaincode лишь проверяет то, что клиент уже
подписал.
Что именно подписывается
Ключевая идея в том, что алгоритм не подписывает конверт целиком и не подписывает «сырые» байты
запроса. Он подписывает одну каноническую строку, которую строит TransportCryptoManager.toSign:
`${name}_${nonce}`— если у команды нет полезной нагрузки;`${name}_${value}_${nonce}`— гдеvalueэто детерминированный JSON запроса, полученный черезObjectUtil.sortKeys(request, true).
Сортировка ключей здесь принципиальна: и клиент при подписи, и chaincode при проверке должны
получить байт в байт одну и ту же строку независимо от того, в каком порядке поля легли в
объект. Благодаря этому подпись покрывает имя команды, её аргументы и nonce сразу — подменить
что-либо из этого, не сломав подпись, нельзя. А поскольку строку собирают обе стороны из одних и
тех же данных, все алгоритмы подписывают, по сути, один и тот же материал — различается только сам
криптографический примитив.
Абстракция подписи
Подпись описывается интерфейсом ISignature (класс Signature) из @ts-core/common — это объект
из четырёх полей:
| Поле | Назначение |
|---|---|
value | сама подпись (hex-строка) |
publicKey | публичный ключ подписанта (для Metamask — Ethereum-адрес) |
nonce | числовая строка, монотонно растущая на пользователя — см. «Безопасность» |
algorithm? | имя алгоритма; опционально, Ed25519 считается умолчанием и по сети не передаётся |
Ключи живут парой IKeyAsymmetric — { privateKey, publicKey }. А конкретный алгоритм прячется за
ITransportCryptoManager с методами sign(command, nonce, privateKey), verify(command, signature)
и геттером algorithm. Все реализации наследуют абстрактный TransportCryptoManager, чей
статический sign ставит nonce = Date.now(), если его не передали, и возвращает готовый
ISignature. Эта единообразная абстракция и позволяет менять алгоритм, не трогая остальной код:
chaincode выбирает нужный менеджер по полю algorithm в подписи.
Поддерживаемые алгоритмы
Каждый алгоритм поставляется отдельным пакетом (кроме Ed25519, встроенного в @ts-core/common) и
даёт свой TransportCryptoManager*. Чтобы chaincode принимал алгоритм, соответствующий менеджер
достаточно добавить в массив cryptoManagers приёмника.
| Алгоритм | Менеджер | Хеш | Основа / примечание |
|---|---|---|---|
| Ed25519 (умолчание) | TransportCryptoManagerEd25519 | SHA-512 | tweetnacl, встроен в @ts-core/common |
| ECDSA secp256k1 | TransportCryptoManagerSecp256k1 | SHA-256 | @noble/curves; есть signDigest/recover/address (Ethereum) и custody-режим над готовым digest |
| RSA | TransportCryptoManagerRSA | SHA-256 | node-rsa, PKCS8 PEM, 2048 бит |
| ГОСТ Р 34.10 | TransportCryptoManagerGostR3410 | ГОСТ Р 34.11 | @vostokplatform/crypto-gost-js, российская криптография |
| Metamask | TransportCryptoManagerMetamaskBackend / ...Frontend | Keccak-256 | EIP-191 personal_sign; publicKey это Ethereum-адрес, verify восстанавливает его из подписи |
Отдельного внимания заслуживает secp256k1: помимо обычной подписи он умеет подписывать и проверять готовый
32-байтный digest (signDigest / verifyDigest), что нужно для custody-сценариев и порогового
подписания (DKLS23), а также вычислять Ethereum-адрес из ключа. Пакеты @ts-core/eth и @ts-core/btc
криптографию не реализуют — это read-only клиенты блокчейна; для подписи Ethereum используется пара
secp256k1 / Metamask.
Подключение алгоритмов на chaincode
Какие алгоритмы chaincode согласен принимать — задаётся массивом cryptoManagers при создании
приёмника. В простом случае это один Ed25519:
import { TransportCryptoManagerEd25519 } from '@ts-core/common';
import { TransportFabricChaincodeReceiver } from '@hlf-core/transport-chaincode';
const transport = new TransportFabricChaincodeReceiver(logger, {
cryptoManagers: [new TransportCryptoManagerEd25519()],
nonSignedCommands: [CoinGetCommand.NAME, UserGetCommand.NAME],
});
Добавить алгоритм — значит добавить его менеджер в этот массив; Ed25519 живёт в @ts-core/common,
остальные — в отдельных пакетах. Контракт может одновременно принимать несколько алгоритмов, и тогда
клиент волен подписывать команду любым из них:
import { TransportCryptoManagerEd25519 } from '@ts-core/common';
import { TransportCryptoManagerRSA } from '@ts-core/crypto-rsa';
import { TransportCryptoManagerGostR3410 } from '@ts-core/crypto-gost';
import { TransportCryptoManagerSecp256k1 } from '@ts-core/crypto-secp256k1';
const transport = new TransportFabricChaincodeReceiver(logger, {
cryptoManagers: [
new TransportCryptoManagerEd25519(),
new TransportCryptoManagerRSA(),
new TransportCryptoManagerGostR3410(),
new TransportCryptoManagerSecp256k1(),
],
});
Больше ничего менять не нужно: при проверке validateSignature выбирает менеджер по полю
signature.algorithm, и команда, подписанная любым из зарегистрированных алгоритмов, пройдёт, а
подписанная незарегистрированным — будет отклонена с CommandSignatureAlgorithmUnknownError.
Поддержка ГОСТ Р 34.10 рядом с Ed25519 здесь не случайна — для регуляторного контура важна
совместимость с российской криптографией, и сменный менеджер позволяет держать несколько схем в одном
контракте.
Поток подписи и проверки
На клиенте подпись строится в несколько строк — берём ключи, формируем команду, подписываем её и кладём подпись в опции при отправке:
import { Ed25519, IKeyAsymmetric, ISignature, TransportCommand, TransportCryptoManagerEd25519 } from '@ts-core/common';
// Ключи подписанта
const keys: IKeyAsymmetric = Ed25519.from(privateKey);
// Любая команда, меняющая состояние
const command = new CoinTransferCommand({ coinUid, object, target, value });
// Подпись команды приватным ключом подписанта
const signature: ISignature = await TransportCommand.sign(
command,
new TransportCryptoManagerEd25519(),
keys,
);
// userId и signature уедут в chaincode внутри опций команды
await transport.sendListen(command, { userId, signature });
Опции команды — это тот самый конверт
TransportFabricCommandOptions, в котором едут userId и
signature. На стороне chaincode
TransportFabricChaincodeReceiver в методе validateSignature проверяет, что подпись, её
algorithm и publicKey присутствуют, находит менеджер по алгоритму в списке cryptoManagers
(незнакомый алгоритм — отказ) и вызывает TransportCryptoManager.verify. Менеджер пересобирает ту
же каноническую строку name_value_nonce уже из пришедшей команды и проверяет signature.value
ключом signature.publicKey.
Напомню важное следствие из примеров регистрации выше: chaincode принимает ровно те алгоритмы,
менеджеры которых ему зарегистрировали. А как проверка подписи увязана с защитой от повторного
воспроизведения через nonce — в разделе «Безопасность», и как подпись доходит до
конкретного обработчика команды — в разделе «Обработчики команд».