Replay protection

Replay Protection

Replay protection is a mechanism to prevent replay attacks, which consist of a malicious user resubmitting an already executed transaction (often shortened to "tx" in this document) to the ledger.

A replay attack causes the state of the machine to deviate from the intended one (from the perspective of the parties involved in the original transaction) and causes economic damage to the fee payer of the original transaction, who finds themselves paying more than once. Further economic damage is caused if the transaction involved the moving of value in some form (e.g. a transfer of tokens) with the sender being deprived of more value than intended.

Since the original transaction was already well formatted for the protocol's rules, the attacker doesn't need to rework it, making this attack relatively easy.

Of course, a replay attack makes sense only if the attacker differs from the source of the original transaction, as a user will always be able to generate another semantically identical transaction to submit without the need to replay the same one.

To prevent this scenario, Namada supports a replay protection mechanism to prevent the execution of already processed transactions.

Context

This section illustrates the pre-existing context in which the replay protection mechanism is implemented.

Encryption-Authentication

The current implementation of Namada is built on top of CometBFT, which provides an encrypted and authenticated communication channel between every two nodes to prevent a man-in-the-middle attack (see the detailed spec (opens in a new tab)).

The Namada protocol relies on this substrate to exchange transactions (messages) that define the state transition of the ledger. More specifically, a transaction is defined as follows:

pub struct Tx {
  /// Type indicating how to process transaction
  pub header: Header,
  /// Additional details necessary to process transaction
  pub sections: Vec<Section>,
}
 
pub struct Header {
  /// The chain which this transaction is being submitted to
  pub chain_id: ChainId,
  /// The time at which this transaction expires
  pub expiration: Option<DateTimeUtc>,
  /// A transaction timestamp
  pub timestamp: DateTimeUtc,
  /// The commitments to the transaction's sections
  pub batch: HashSet<TxCommitments>,
  /// Whether the inner txs should be executed atomically
  pub atomic: bool,
  /// The type of this transaction
  pub tx_type: TxType,
}
 
pub enum TxType {
    /// An ordinary tx
    Raw,
    /// A Tx that contains a payload in the form of a raw tx
    Wrapper(Box<WrapperTx>),
    /// Txs issued by validators as part of internal protocols
    Protocol(Box<ProtocolTx>),
}
 
pub struct WrapperTx {
  /// The fee to be paid for including the tx
  pub fee: Fee,
  /// Used for signature verification and to determine an implicit
  /// account of the fee payer
  pub pk: common::PublicKey,
  /// Max amount of gas that can be used when executing the inner tx
  pub gas_limit: GasLimit,
}

The transaction is composed of an header carrying some metadata about the transaction itself and a vector of sections that represents the different components of the transaction, such as the code, inputs, signatures and auxiliary data.

A transaction is constructed as follows:

  1. The struct Tx is produced with a header marked as TxType::Raw
  2. The producer(s) signs the hash of the header and attaches this signature to the tx with a Section::Authorization
  3. The gas payer (which could be a different entity) changes the tx type to TxType::Wrapper and adds the necessary information.' It then proceeds to sign the vector of the hashes of this header and the hash of all the sections and attach another authorization section to the tx

In the execution steps:

  1. The WrapperTx signature is verified and, only if valid, the tx is processed
  2. The inner txs will then be executed by the WASM runtime
  3. After the execution, the affected validity predicates (also mentioned as VPs in this document) will check the storage changes and (if relevant) the signature of the transaction. If the signature is not valid, the VP will deem the transaction invalid and the changes won't be applied to the storage

The transaction data is effectively prevented from being tampered with by the signature checks, since any such tampering would cause the checks to fail and the transaction to be rejected. For a more in-depth view, please refer to the Namada execution spec. Also, the gas payer can only change the TxType, they cannot change the rest of the header data: if they did, then the inner transactions signature verification would fail since the hash would not match the one that was originally signed: this is to prevent the gas payer from modifying the terms of the transaction that the original submitter intended.

CometBFT replay protection

A first layer of protection is provided in the mempool of the underlying consensus engine, CometBFT (opens in a new tab), which is based on a cache of transactions previously seen. This mechanism is aimed at preventing an already processed transaction from being included in the next block by a block proposer, which can occur when the transaction is received late. Of course, this also acts as a countermeasure against intentional replay attacks. However, this check, like all the checks performed in CheckTx, is weak, as a block containing invalid transactions could still be proposed by a malicious validator. Therefore, there is a need for a more robust replay protection mechanism implemented directly in the application.

Implementation

Namada replay protection consists of three parts: the hash-based solution for the transactions, a way to mitigate replay attacks in case of a fork, and a concept of a lifetime.

Hash register

As mentioned in the previous section, a transaction carries a signature from the gas payer and some optional signatures on the raw header hash coming from the involved parties. The replay protection mechanism must therefore protect both entities that are covered by these signatures: the TxType::Wrapper header and the TxType::Raw header. The raw header must be protected to prevent replays of the inner transactions in the batch that carry the desired state modifications. The wrapper header must be protected because otherwise, even if the batch was protected, the gas payer could be forced to pay fees more than once.

Note that only the headers need to be protected, there's no need to take into considerations the transaction's Sections since:

  1. The headers contain commitments to these sections anyways so no malleability attacks are possible
  2. If sections were taken into consideration, then a malicious user could force replays of the transaction by simply reordering the sections themselves or adding/removing some (like authorizations or just dummy sections)

Also, the headers contain the commitments to all the transactions of the batch. If an attacker tried to extract a single one of them to replay it, it would still be required to produce a valid signature on it, which would be impossible.

To prevent the replay of both these entities, reliance is placed on a set of digests from already processed transactions' headers that are kept in storage. To support this, a subspace in storage is required, headed by a ReplayProtection internal address:

/\$ReplayProtectionAddress/\$tx0_hash: None
/\$ReplayProtectionAddress/\$tx1_hash: None
/\$ReplayProtectionAddress/\$tx2_hash: None
...

The hashes are positioned at the end of the path to enable rapid storage lookups.

The consistency of the storage subspace is critically important for the correct functioning of the replay protection mechanism. To safeguard it, a validity predicate will verify that no changes to this subspace are applied by any wasm transaction, as these changes should only originate from the protocol.

In both mempool_validation and process_proposal, a check is performed (in conjunction with other checks, as described in the relative section) on the digests against the storage to ensure that the transaction has not been executed yet. If this condition is not met, the tx is not included in the mempool or block, respectively. In process_proposal a temporary cache is used to prevent the replay of a wrapper transaction in the same block; it is instead allowed to include more than one transaction with the same raw header hash, since we cannot know, before execution, if the transaction(s) is valid, and we'll end up commiting its hash. If both headers checks pass, the transaction is included in the block. The wrapper hash is then committed to storage in finalize_block. When we later execute the transactions' batches we check that the raw header hash is not present in storage (i.e. applied previously in this block) and we execute it. Depending on the outcome, we commit its hash to storage (we avoid committing the hash only when the transaction runs out of gas or the commitments to the sections are invalid). If the raw header hash end up being committed we remove the corresponding wrapper header hash since it's redundant at this point.

Governance proposals

Governance proposals may carry some wasm code to be executed in case the proposal passed. This code is embedded into a raw transaction directly by the validators at block processing time and is not inserted into the block itself.

Given that the wasm code is attached to the transaction initiating the proposal, it could be extracted from here and inserted in a transaction before the proposal is executed. Therefore, replay protection is not a solution to prevent attacks on governance proposal code. Instead, to protect these transactions, Namada relies on its proposal ID mechanism in conjunction with the VP set.

Protocol transactions

At the moment, protocol transactions are only used for ETH bridge related operations. The current implementation already takes care of replay attempts by keeping track of the validators' signature on the events: this also includes replay attacks in the same block.

In the future, new types of protocol transactions may be supported: in this case, a review of the replay protection mechanism might be required.

Forks

In the case of a fork, replay attacks are not prevented by the transaction hash alone. Transactions could still be replayed on the other branch as long as their format remains unchanged.

To mitigate this problem, transactions need to carry a ChainId identifier to tie them to a specific fork. This field can be found in the Header struct so that it applies to both the wrapper and the inner transactions.

This new field is signed just like the other ones and is therefore subject to the same guarantees explained in the initial section. The validity of this identifier is checked in process_proposal. If a transaction carries an unexpected chain ID, it won't be accepted, meaning that no modifications will be applied to storage.

Transaction lifetime

In general, a transaction is valid at the moment of submission, but after that, various external factors (ledger state, etc.) might change the submitter's intent. The submitter may no longer be interested in the execution of the transaction.

The concept of a lifetime (or timeout) for transactions needs to be introduced. The Header struct will include an optional additional field called expiration, indicating the maximum DateTimeUtc by which the submitter is willing to see the transaction executed. After the specified time, the transaction is considered invalid and discarded, irrespective of other checks.

By introducing this new field, a new constraint is added to the transaction's contract. The ledger ensures that the transaction is prevented from execution after the deadline. On the other hand, the submitter commits to the execution outcome until its expiration. If the expiration is reached and the transaction has not been executed, the submitter can decide to submit a new transaction if still interested in the changes carried by it.

In the current design, the expiration holds until the transaction is executed. Once executed, the transaction hash is committed to storage, preventing further replays (regardless of whether the tx was successful or not). Essentially, the transaction submitter commits to one of these three conditions:

  • Transaction is invalid regardless of the specific state
  • Transaction is executed (either with success or not) and the transaction hash is saved in the storage
  • Expiration time has passed

Any satisfied condition invalidates further executions of the same transaction.

Wrapper checks

In mempool_validation, several checks are performed on the transaction to validate it. These checks include:

  • Signature
  • GasLimit is below the block gas limit
  • Fees are paid with an accepted token and match the minimum required amount
  • ChainId
  • Replay protection
  • Expiration
  • Sufficient funds for wrapper signer to pay the fee (potentially via the MASP)

More details about gas and fees can be found in the fee specs.

These checks can and must all be conducted before executing the transactions themselves. If any of these checks fail, the transaction is considered invalid, and the appropriate action is taken:

  1. If the checks fail for signature, chainId, expiration or replay protection, the transaction is permanently invalid. It does not need to be included in the block. Furthermore, including the transaction in the block to impose a fee (as a form of punishment) is not possible, as these errors may not be due to the signer of the transaction (could be caused by malicious users or simply delayed transaction inclusion in the block).
  2. If the checks fail for Fee or GasLimit, the transaction is discarded. In theory, the gas limit of a block is a Namada parameter governed by governance. Thus, the transaction could become valid in the future if this limit is increased. The same principle applies to the token whitelist and the minimum required fee. However, these parameters are expected to change slowly, so rejecting the transaction is reasonable (the submitter can always resubmit it in the future).

If all checks pass validation, the transaction is included in the block to store the hash and apply the fee.

All of these checks are also run in process_proposal.

Block rejection

To prevent the inclusion of invalid transactions in a block by a block proposer, the validators reject the entire block if they encounter a single invalid transaction in accordance with the checks delineated in the previous section.

Rejecting only the single invalid transaction while still allowing the acceptance of the entire block constitutes an inadequate solution. In this scenario, the block proposer lacks the motivation to incorporate invalid transactions into the block, as these do not yield any fees. However, simultaneously, there exists no real disincentive for the proposer to exclude such transactions. In such cases, validators simply discard the invalid transaction while approving the remainder of the block, thereby enabling the proposer to collect fees from all other transactions. This circumstance applies, of course, when the proposer lacks other valid transactions to include. A malicious proposer could employ this strategy to inundate the block with spam without facing any penalties.