Skip to main content

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}` — where value is the deterministic JSON of the request, obtained via ObjectUtil.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:

FieldPurpose
valuethe signature itself (hex string)
publicKeythe signer's public key (for Metamask — an Ethereum address)
noncea 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.

AlgorithmManagerHashBasis / note
Ed25519 (default)TransportCryptoManagerEd25519SHA-512tweetnacl, built into @ts-core/common
ECDSA secp256k1TransportCryptoManagerSecp256k1SHA-256@noble/curves; has signDigest/recover/address (Ethereum) and a custody mode over a ready digest
RSATransportCryptoManagerRSASHA-256node-rsa, PKCS8 PEM, 2048 bit
GOST R 34.10TransportCryptoManagerGostR3410GOST R 34.11@vostokplatform/crypto-gost-js, Russian cryptography
MetamaskTransportCryptoManagerMetamaskBackend / ...FrontendKeccak-256EIP-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.