Cryptography and Command Signing
Signing in hlf-core is not part of Fabric but an application-level concern: Fabric confirms that a transaction
came from an organization's node, whereas hlf-core answers the question "who exactly performed it". That is why all
cryptography is taken from @ts-core/common and its crypto packages and stays the same whether we are signing
a command for the blockchain or for an ordinary service — the chaincode merely verifies what the client has already
signed.
What exactly is signed
The key idea is that the algorithm does not sign the whole envelope and does not sign the "raw" bytes of the
request. It signs a single canonical string that TransportCryptoManager.toSign builds:
`${name}_${nonce}`— if the command has no payload;`${name}_${value}_${nonce}`— wherevalueis the deterministic JSON of the request, obtained viaObjectUtil.sortKeys(request, true).
Sorting the keys here is essential: both the client when signing and the chaincode when verifying must
obtain byte for byte the same string regardless of the order in which the fields ended up in the
object. Thanks to this, the signature covers the command name, its arguments and the nonce all at once — none
of these can be tampered with without breaking the signature. And since both sides assemble the string from the same
data, all algorithms essentially sign the same material — only the
cryptographic primitive itself differs.
The signature abstraction
A signature is described by the ISignature interface (class Signature) from @ts-core/common — an object
of four fields:
| Field | Purpose |
|---|---|
value | the signature itself (hex string) |
publicKey | the signer's public key (for Metamask — an Ethereum address) |
nonce | a numeric string, monotonically increasing per user — see Security |
algorithm? | the algorithm name; optional, Ed25519 is treated as the default and is not transmitted over the network |
Keys live as an IKeyAsymmetric pair — { privateKey, publicKey }. And the concrete algorithm is hidden behind
ITransportCryptoManager with the methods sign(command, nonce, privateKey), verify(command, signature)
and the algorithm getter. All implementations inherit from the abstract TransportCryptoManager, whose
static sign sets nonce = Date.now() if it was not passed, and returns a ready
ISignature. This uniform abstraction is what allows the algorithm to be changed without touching the rest of the code:
the chaincode selects the needed manager by the algorithm field in the signature.
Supported algorithms
Each algorithm ships as a separate package (except Ed25519, which is built into @ts-core/common) and
provides its own TransportCryptoManager*. For the chaincode to accept an algorithm, it is enough to add the corresponding manager
to the receiver's cryptoManagers array.
| Algorithm | Manager | Hash | Basis / note |
|---|---|---|---|
| Ed25519 (default) | TransportCryptoManagerEd25519 | SHA-512 | tweetnacl, built into @ts-core/common |
| ECDSA secp256k1 | TransportCryptoManagerSecp256k1 | SHA-256 | @noble/curves; has signDigest/recover/address (Ethereum) and a custody mode over a ready digest |
| RSA | TransportCryptoManagerRSA | SHA-256 | node-rsa, PKCS8 PEM, 2048 bit |
| GOST R 34.10 | TransportCryptoManagerGostR3410 | GOST R 34.11 | @vostokplatform/crypto-gost-js, Russian cryptography |
| Metamask | TransportCryptoManagerMetamaskBackend / ...Frontend | Keccak-256 | EIP-191 personal_sign; publicKey is an Ethereum address, verify recovers it from the signature |
secp256k1 deserves separate attention: besides an ordinary signature it can also sign and verify a ready
32-byte digest (signDigest / verifyDigest), which is needed for custody scenarios and threshold
signing (DKLS23), and it can also compute an Ethereum address from a key. The packages @ts-core/eth and @ts-core/btc
do not implement cryptography — they are read-only blockchain clients; for Ethereum signing the
secp256k1 / Metamask pair is used.
Wiring algorithms into the chaincode
Which algorithms the chaincode agrees to accept is set by the cryptoManagers array when creating the
receiver. In the simplest case this is a single 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],
});
Adding an algorithm means adding its manager to this array; Ed25519 lives in @ts-core/common,
the rest — in separate packages. A contract can accept several algorithms at once, and then the
client is free to sign a command with any of them:
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(),
],
});
Nothing else needs to be changed: during verification validateSignature selects the manager by the
signature.algorithm field, and a command signed with any of the registered algorithms will pass, while one
signed with an unregistered one will be rejected with CommandSignatureAlgorithmUnknownError.
Support for GOST R 34.10 alongside Ed25519 is no accident here — for a regulatory perimeter compatibility
with Russian cryptography matters, and the pluggable manager makes it possible to keep several schemes in one
contract.
The signing and verification flow
On the client the signature is built in a few lines — we take the keys, form the command, sign it and put the signature into the options when sending:
import { Ed25519, IKeyAsymmetric, ISignature, TransportCommand, TransportCryptoManagerEd25519 } from '@ts-core/common';
// Signer's keys
const keys: IKeyAsymmetric = Ed25519.from(privateKey);
// Any state-changing command
const command = new CoinTransferCommand({ coinUid, object, target, value });
// Sign the command with the signer's private key
const signature: ISignature = await TransportCommand.sign(
command,
new TransportCryptoManagerEd25519(),
keys,
);
// userId and signature will travel to the chaincode inside the command options
await transport.sendListen(command, { userId, signature });
The command options are that very envelope
TransportFabricCommandOptions, in which userId and
signature travel. On the chaincode side
TransportFabricChaincodeReceiver, in the validateSignature method, checks that the signature, its
algorithm and publicKey are present, finds the manager by algorithm in the cryptoManagers list
(an unknown algorithm means rejection) and calls TransportCryptoManager.verify. The manager reassembles the
same canonical string name_value_nonce from the incoming command and verifies signature.value
with the signature.publicKey key.
Let me recall an important consequence of the registration examples above: the chaincode accepts exactly those algorithms
whose managers were registered with it. And how signature verification is tied to replay
protection via nonce is covered in the Security section, and how the signature reaches a
specific command handler — in the Command Handlers section.