diff --git a/packages/specs/pages/base-ledger/replay-protection.mdx b/packages/specs/pages/base-ledger/replay-protection.mdx index 6e0ce2e9..972b1a32 100644 --- a/packages/specs/pages/base-ledger/replay-protection.mdx +++ b/packages/specs/pages/base-ledger/replay-protection.mdx @@ -25,107 +25,112 @@ prevent the execution of already processed transactions. ## Context -This section illustrates the pre-existing context in the replay protection mechanism is implemented. +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 +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 {/* TODO: Fix link to be cometbft is possible*/} [spec](https://github.com/cometbft/cometbft/blob/main/spec/p2p/legacy-docs/peer.md)). The Namada protocol relies on this substrate to exchange transactions (messages) -that defines the state transition of the ledger. More specifically, a -transaction is composed of two parts: a `WrapperTx` and an inner `Tx` +that define the state transition of the ledger. More specifically, a +transaction is defined as follows: -{/* TODO: Check that this is up to date. I believe it is not */} ```rust +pub struct Tx { + /// Type indicating how to process transaction + pub header: Header, + /// Additional details necessary to process transaction + pub sections: Vec
, +} + +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, + /// A transaction timestamp + pub timestamp: DateTimeUtc, + /// The commitments to the transaction's sections + pub batch: HashSet, + /// 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), + /// Txs issued by validators as part of internal protocols + Protocol(Box), +} + pub struct WrapperTx { /// The fee to be paid for including the tx pub fee: Fee, - /// Used to determine an implicit account of the fee payer + /// Used for signature verification and to determine an implicit + /// account of the fee payer pub pk: common::PublicKey, - /// The epoch in which the tx is to be submitted. This determines - /// which decryption key will be used - pub epoch: Epoch, /// Max amount of gas that can be used when executing the inner tx pub gas_limit: GasLimit, - /// The optional unshielding tx for fee payment - pub unshield: Option, - /// the encrypted payload - pub inner_tx: EncryptedTx, - /// sha-2 hash of the inner transaction acting as a commitment - /// the contents of the encrypted payload - pub tx_hash: Hash, -} - -pub struct Tx { - pub code: Vec, - pub data: Option>, - pub timestamp: DateTimeUtc, } ``` -The wrapper transaction is composed of some metadata, an optional unshielding tx -for fee payment (see [fee specs](./fee-system.mdx)), the encrypted -inner transaction itself and the hash of the concatenation of these values. {/* TODO: Ensure that the hash of concatenation statement is as accurate as possible. It could be values, it could be the object (which consists of keys + values)*/} -The inner `Tx` transaction carries the Wasm code to be executed and the associated data. +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 transactionm, +such as the code, inputs, signatures and auxillary data. A transaction is constructed as follows: -1. The struct `Tx` is produced -2. The hash of this transaction gets signed by the author, producing another - `Tx` where the data field holds the concatenation of the original data and - the signature (`SignedTxData`) -3. The produced transaction is encrypted and embedded in a `WrapperTx`. The - encryption step exists for a future implementation of threshold decryption - scheme (see [Ferveo](https://github.com/anoma/ferveo)) -4. Finally, the `WrapperTx` is converted to a `Tx` struct, signed over its - hash (same as step 2, relying on `SignedTxData`), and submitted to the - network - -Note that the signer of the `WrapperTx` and that of the inner one don't need to -coincide, but the signer of the wrapper is charged with gas and fees. In -the execution steps: +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. In the following height the proposer decrypts the inner tx, checks that the - hash matches that of the `tx_hash` field and, if everything went well, - includes the decrypted tx in the proposed block -3. The inner tx will then be executed by the WASM runtime -4. After the execution, the affected validity predicates (also mentioned as VPs +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 + 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](./execution.mdx). +[Namada execution spec](./execution.mdx). 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](https://github.com/cometbft/cometbft/blob/main/spec/abci/abci%2B%2B_app_requirements.md#connection-state), -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. +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 both `EncryptedTx` (also referred to as the `InnerTx`) and `WrapperTx`, a way to mitigate replay attacks in case of a fork, and a concept of a lifetime for transactions. +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 -The actual Wasm code and data for the transaction are encapsulated inside a -struct `Tx`, which gets encrypted as an `EncryptedTx` and wrapped inside a -`WrapperTx` (see the [relative](#encryption-authentication) section). This inner -transaction must be protected from replay attacks because it carries the actual -semantics of the state transition. This inner transaction must be protected from replay attacks, as it carries the actual semantics of the state transition. Furthermore, even if the wrapper transaction were protected from replay attacks, the inner transaction could still be extracted by an attacker, rewrapped, and replayed. It should be noted that for this attack to succeed, the attacker would need to sign the outer transaction themselves and pay gas and fees for it. However, this could still cause significant damage to the parties involved in the inner transaction. +As mentioned in the [previous section](#encryption-authentication), 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 `Section`s since: -`WrapperTx` is the only type of transaction currently accepted by the ledger. It must be protected from replay attacks; otherwise, a malicious user could replay the transaction as is. Even if the inner transaction implemented replay protection or was not accepted for any reason, the signer of the wrapper would still incur gas and fees, resulting in economic damage. +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) -To prevent the replay of both these transactions, reliance is placed on a set of digests from already processed transactions that are kept in storage. These digests are computed on the **unsigned** transactions, to support replay -protection even for [multisigned](../modules/multisignature.mdx) transactions. In this case, using hashes from the signed transactions would result in a different hash for a different set of signatures on the same transaction, allowing for a replay. To achieve this, the `WrapperTx` hash field contains the hash of the unsigned inner transaction, instead of the signed one. This change does not impact the overall safety of Namada, as the wrapper is still signed over all its bytes, including the inner signature. The modification also allows for early replay attack checks in the mempool and during wrapper block-inclusion. +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: -In addition, a subspace in storage is required, headed by a `ReplayProtection` internal address: ``` bash /\$ReplayProtectionAddress/\$tx0_hash: None /\$ReplayProtectionAddress/\$tx1_hash: None @@ -137,57 +142,24 @@ 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](#wrapper-checks)) on the digests against the storage to ensure that neither of the transactions has already been executed. If this condition is not met, the `WrapperTx` is not included in the mempool or block, respectively. In `process_proposal` a -temporary cache is used to prevent the replay of a transaction in the same block. If both -checks pass, the transaction is included in the block. The hashes are -committed to storage in `finalize_block` and the transaction is executed. - -In the subsequent block, the inner transaction is deserialized, and the validity of the decrypted transactions and their correct order are verified. If the order is incorrect, a new round of CometBFT commences. If an error is found in any individual decrypted transaction, the previously inserted hash of the inner transaction is removed from storage to allow for rewrapping, and the transaction itself is discarded. Finally, in `finalize_block` the transaction is executed. If the transaction runs out of gas, its hash will be removed from storage to enable rewrapping and execution of the transaction. Otherwise, the hash will remain in storage, regardless of the success or failure of the transaction. - -#### Optional unshielding - -The optional `unshield` field is supposed to carry an unshielding masp -`Transaction`. Given this assumption, there's no need to manage it since masp -has an internal replay protection mechanism. - -Still, since this field represents a valid, `Transaction`, there is a possible -attack that can be run by leveraging this field: a malicious user could extract -this data before it makes it to a block, embed it into a valid, masp signed -`Tx` and apply it in advance. - -This attack is performed before the original tx is placed in a block and, -therefore, cannot be prevented with a replay protection mechanism. The only -result of this attack would be that the original wrapper transaction would fail -since it would attempt to replay a masp transfer: in this case, the submitter of -the original tx can recreate it without the need for the unshielding operation -since the attacker has already performed it. - -Given that saving the hash of the unshielding transaction is redundant in case -of a proper masp transfer, Namada does not implement the replay protection -mechanism on the unshielding transaction, whose correctness is left to the masp -validity predicate. The combination of the fee system, the validity predicates -set and the protocol checks on the unshielding operation guarantees that even if -the attack explained in this section is performed: - -- The original wrapper signer doesn't suffer economic damage (the wrapper - containing the invalid unshielding forces the block rejection without fee - collection) -- The attacker has to pay fees on the rewrapped tx preventing him to submit - these transactions for free +In both `mempool_validation` and `process_proposal`, a check is performed +(in conjunction with other checks, as described in the [relative section](#wrapper-checks)) 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](../modules/governance.mdx) may carry some wasm code to be executed in case the proposal passed. This code is embedded into a -`DecryptedTx` directly by the validators at block processing time and is not +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 proposals' code. Instead, to protect these transactions, -Namada relies on its proposal id mechanism in conjunction with the VP set. +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 @@ -201,29 +173,21 @@ 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 and the counters in storage match. +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 must be added to the `Tx` struct -so that it applies to both `WrapperTx` and `EncryptedTx`: +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. -```rust -pub struct Tx { - pub code: Vec, - pub data: Option>, - pub timestamp: DateTimeUtc, - pub chain_id: ChainId -} -``` - -This new field is signed just like the other ones and is therefore subject to the same guarantees explained in the [initial](#encryption-authentication) section. The validity of this identifier is checked in `process_proposal` -for both the outer and inner tx: for both the outer and inner transactions. If a transaction carries an unexpected chain id, it won't be applied, meaning that no modifications will be applied to storage. +This new field is signed just like the other ones and is therefore subject to the same guarantees explained in the [initial](#encryption-authentication) 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 `Tx` 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. +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. @@ -236,55 +200,31 @@ In the current design, the `expiration` holds until the transaction is executed. Any satisfied condition invalidates further executions of the same transaction. -```rust -pub struct Tx { - pub code: Vec, - pub data: Option>, - pub timestamp: DateTimeUtc, - pub chain_id: ChainId, - /// Optional lifetime of the transaction - pub expiration: Option, -} -``` - -The wrapper transaction must match the `expiration` of the inner transaction (if any). This field is also needed for the wrapper to anticipate the check at mempool/proposal evaluation time. Additionally, it prevents someone from inserting a wrapper transaction after the corresponding inner transaction has expired, compelling the wrapper signer to pay the fees. - ### Wrapper checks -In mempool_validation, several checks are performed on the wrapper transaction to validate it. These checks include: +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 -- Transaction hash +- Replay protection - Expiration -- Sufficient funds for wrapper signer to pay the fee -- Unshielding transaction (if present) is a valid masp unshielding transfer -- Unshielding transaction (if present) releases the minimum required tokens for fee payment -- Successful execution of the unshielding transaction (if present) +- Sufficient funds for wrapper signer to pay the fee (potentially via the MASP) -More details about gas, fees, and the unshielding transaction can be found in the [fee specs](./fee-system.mdx). +More details about gas and fees can be found in the [fee specs](./fee-system.mdx). -These checks can 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: +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, transaction hash, balance, or the unshielding transaction, 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). +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`. -This mechanism can also be applied to another scenario. Suppose a transaction was not propagated to the network by a node (or a group of colluding nodes). This transaction might be valid, but it is not inserted into a block. Without an expiration, this transaction could be replayed (more accurately, applied, as it was never executed in the first place) at a later time when the submitter may no longer wish to execute it. - ### 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 wrapper transaction. +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](#wrapper-checks). 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. - -In summary, a block faces rejection if at least one of the following conditions is satisfied: - -- One or more `WrapperTx` transactions are invalid in accordance with the checks delineated in the - [relative section](#wrapper-checks) -- The order or number of decrypted transactions differs from the order or number committed in the previous block diff --git a/packages/specs/pages/base-ledger/replay-protection/_meta.json b/packages/specs/pages/base-ledger/replay-protection/_meta.json deleted file mode 100644 index fbfdfa33..00000000 --- a/packages/specs/pages/base-ledger/replay-protection/_meta.json +++ /dev/null @@ -1,3 +0,0 @@ -{ - "optimizations": "Possible optimizations" -} \ No newline at end of file diff --git a/packages/specs/pages/base-ledger/replay-protection/optimizations.mdx b/packages/specs/pages/base-ledger/replay-protection/optimizations.mdx deleted file mode 100644 index c4f751a4..00000000 --- a/packages/specs/pages/base-ledger/replay-protection/optimizations.mdx +++ /dev/null @@ -1,646 +0,0 @@ -import { Callout } from 'nextra-theme-docs' - -{/* TODO: Maket this section more clear. At the moment I'm not sure what optimisation is what in the "possible optimisations" */} -# Possible optimizations - -In this section we describe two alternative solutions that come with some optimizations. - -## Transaction counter - -Instead of relying on a hash (32 bytes) Namada could use a 64 bits (8 bytes) -transaction counter as nonce for the wrapper and inner transactions. The -advantage is that the space required would be much less since only two 8 -bytes values in storage are needed for every address which is signing transactions. On the -other hand, the handling of the counter for the inner transaction is -performed entirely in wasm (transactions and VPs) making it slightly less -efficient. This solution also imposes a strict ordering on the transactions -issued by a same address. - - -**NOTE**: this solution requires the ability to -[yield](https://github.com/wasmerio/wasmer/issues/1127) execution from Wasmer -which is not implemented. - - - -### InnerTx - -We will implement the protection entirely in Wasm: the check of the counter will -be carried out by the validity predicates while the actual writing of the -counter in storage will be done by the transactions themselves. - -To do so, the `SignedTxData` attached to the transaction will hold the current -value of the counter in storage: - -```rust -pub struct SignedTxData { - /// The original tx data bytes, if any - pub data: Option>, - /// The optional transaction counter for replay protection - pub tx_counter: Option, - /// The signature is produced on the tx data concatenated with the tx code - /// and the timestamp. - pub sig: common::Signature, -} -``` - -The counter must reside in `SignedTxData` and not in the data itself because -this must be checked by the validity predicate which is not aware of the -specific transaction that took place but only of the changes in the storage; -therefore, the VP is not able to correctly deserialize the data of the -transactions since it doesn't know what type of data the bytes represent. - -The counter will be signed as well to protect it from tampering and grant it the -same guarantees explained at the [beginning](#encryption-authentication) of this -document. - -The wasm transaction will simply read the value from storage and increase its -value by one. The target key in storage will be the following: - -``` -/$Address/inner_tx_counter: u64 -``` - -The VP of the _source_ address will then check the validity of the signature -and, if it's deemed valid, will proceed to check if the pre-value of the counter -in storage was equal to the one contained in the `SignedTxData` struct and if -the post-value of the key in storage has been incremented by one: if any of -these conditions doesn't hold the VP will discard the transactions and prevent -the changes from being applied to the storage. - -In the specific case of a shielded transfer, since MASP already comes with -replay protection as part of the Zcash design (see the [MASP specs](../../modules/masp.mdx) -and [Zcash protocol specs](https://zips.z.cash/protocol/protocol.pdf)), the -counter in `SignedTxData` is not required and therefore should be optional. - -To implement replay protection for the inner transaction we will need to update -all the VPs checking the transaction's signature to include the check on the -transaction counter: at the moment the `vp_user` validity predicate is the only -one to update. In addition, all the transactions involving `SignedTxData` should -increment the counter. - -### WrapperTx - -To protect this transaction we can implement an in-protocol mechanism. Since the -wrapper transaction gets signed before being submitted to the network, we can -leverage the `tx_counter` field of the `SignedTxData` already introduced for the -inner tx. - -In addition, we need another counter in the storage subspace of every address: - -``` -/$Address/wrapper_tx_counter: u64 -``` - -where `$Address` is the one signing the transaction (the same implied by the -`pk` field of the `WrapperTx` struct). - -The check will consist of a signature check first followed by a check on the -counter that will make sure that the counter attached to the transaction matches -the one in storage for the signing address. This will be done in the -`process_proposal` function so that validators can decide whether the -transaction is valid or not; if it's not, then they will discard the transaction -and skip to the following one. - -At last, in `finalize_block`, the ledger will update the counter key in storage, -increasing its value by one. This will happen when the following conditions are -met: - -- `process_proposal` has accepted the tx by validating its signature and - transaction counter -- The tx was correctly applied in `finalize_block` (for `WrapperTx` this simply - means inclusion in the block and gas accounting) - -Now, if a malicious user tried to replay this transaction, the `tx_counter` in -the struct would no longer be equal to the one in storage and the transaction -would be deemed invalid. - -### Implementation details - -In this section we'll talk about some details of the replay protection mechanism -that derive from the solution proposed in this section. - -#### Storage counters - -Replay protection will require interaction with the storage from both the -protocol and Wasm. To do so we can take advantage of the `StorageRead` and -`StorageWrite` traits to work with a single interface. - -This implementation requires two transaction counters in storage for every -address, so that the storage subspace of a given address looks like the -following: - -``` -/$Address/wrapper_tx_counter: u64 -/$Address/inner_tx_counter: u64 -``` - -An implementation requiring a single counter in storage has been taken into -consideration and discarded because that would not support batching; see the -[relative section](#single-counter-in-storage) for a more in-depth explanation. - -For both the wrapper and inner transaction, the increase of the counter in -storage is an important step that must be correctly executed. First, the -implementation will return an error in case of a counter overflow to prevent -wrapping, since this would allow for the replay of previous transactions. Also, -we want to increase the counter as soon as we verify that the signature, the -chain id and the passed-in transaction counter are valid. The increase should -happen immediately after the checks because of two reasons: - -- Prevent replay attack of a transaction in the same block -- Update the transaction counter even in case the transaction fails, to prevent - a possible replay attack in the future (since a transaction invalid at state - Sx could become valid at state Sn where `n > x`) - -For `WrapperTx`, the counter increase and fee accounting will per performed in -`finalize_block` (as stated in the [relative](#wrappertx) section). - -For `InnerTx`, instead, the logic is not straightforward. The transaction code -will be executed in a Wasm environment ([Wasmer](https://wasmer.io)) till it -eventually completes or raises an exception. In case of success, the counter in -storage will be updated correctly but, in case of failure, the protocol will -discard all of the changes brought by the transactions to the write-ahead-log, -including the updated transaction counter. This is a problem because the -transaction could be successfully replayed in the future if it will become -valid. - -The ideal solution would be to interrupt the execution of the Wasm code after -the transaction counter (if any) has been increased. This would allow performing -a first run of the involved VPs and, if all of them accept the changes, let the -protocol commit these changes before any possible failure. After that, the -protocol would resume the execution of the transaction from the previous -interrupt point until completion or failure, after which a second pass of the -VPs is initiated to validate the remaining state modifications. In case of a VP -rejection after the counter increase there would be no need to resume execution -and the transaction could be immediately deemed invalid so that the protocol -could skip to the next tx to be executed. With this solution, the counter update -would be committed to storage regardless of a failure of the transaction itself. - -Unfortunately, at the moment, Wasmer doesn't allow -[yielding](https://github.com/wasmerio/wasmer/issues/1127) from the execution. - -In case the transaction went out of gas (given the `gas_limit` field of the -wrapper), all the changes applied will be discarded from the WAL and will not -affect the state of the storage. The inner transaction could then be rewrapped -with a correct gas limit and replayed until the `expiration` time has been -reached. - -#### Batching and transaction ordering - -This replay protection technique supports the execution of multiple transactions -with the same address as _source_ in a single block. Actually, the presence of -the transaction counters and the checks performed on them now impose a strict -ordering on the execution sequence (which can be an added value for some use -cases). The correct execution of more than one transaction per source address in -the same block is preserved as long as: - -1. The wrapper transactions are inserted in the block with the correct ascending - order -2. No hole is present in the counters' sequence -3. The counter of the first transaction included in the block matches the - expected one in storage - -The conditions are enforced by the block proposer who has an interest in -maximizing the amount of fees extracted by the proposed block. To support this -incentive, validators will reject the block proposed if any of the included -wrapper transactions are invalid, effectively incentivizing the block proposer -to include only valid transactions and correctly reorder them to gain the fees. - -In case of a missing transaction causes a hole in the sequence of transaction -counters, the block proposer will include in the block all the transactions up -to the missing one and discard all the ones following that one, effectively -preserving the correct ordering. - -Correctly ordering the transactions is not enough to guarantee the correct -execution. As already mentioned in the [WrapperTx](#wrappertx) section, the -block proposer and the validators also need to access the storage to check that -the first transaction counter of a sequence is actually the expected one. - -The entire counter ordering is only done on the `WrapperTx`: if the inner -counter is wrong then the inner transaction will fail and the signer of the -corresponding wrapper will be charged with fees. This incentivizes submitters to -produce valid transactions and discourages malicious user from rewrapping and -resubmitting old transactions. - -#### Mempool checks - -As a form of optimization to prevent mempool spamming, some of the checks that -have been introduced in this document will also be brought to the -`mempool_validate` function. Of course, we always refer to checks on the -`WrapperTx` only. More specifically: - -- Check the `ChainId` field -- Check the signature of the transaction against the `pk` field of the - `WrapperTx` -- Perform a limited check on the transaction counter - -Regarding the last point, `mempool_validate` will check if the counter in the -transaction is `>=` than the one in storage for the address signing the -`WrapperTx`. A complete check (checking for strict equality) is not feasible, as -described in the [relative](#mempool-counter-validation) section. - -### Alternatives considered - -In this section we list some possible solutions that were taken into -consideration during the writing of this solution but were eventually discarded. - -#### Mempool counter validation - -The idea of performing a complete validation of the transaction counters in the -`mempool_validate` function was discarded because of a possible flaw. - -Suppose a client sends five transactions (counters from 1 to 5). The mempool of -the next block proposer is not guaranteed to receive them in order: something on -the network could shuffle the transactions up so that they arrive in the -following order: 2-3-4-5-1. Now, since we validate every single transaction to -be included in the mempool in the exact order in which we receive them, we would -discard the first four transactions and only accept the last one, that with -counter 1. Now the next block proposer might have the four discarded -transactions in its mempool (since those were not added to the previous block -and therefore not evicted from the other mempools, at least they shouldn't, see -[block rejection](#block-rejection)) and could therefore include them in the -following block. But still, a process that could have ended in a single block -actually took two blocks. Moreover, there are two more issues: - -- The next block proposer might have the remaining transactions out of order in - his mempool as well, effectively propagating the same issue down to the next - block proposer -- The next block proposer might not have these transactions in his mempool at - all - -Finally, transactions that are not allowed into the mempool don't get propagated -to the other peers, making their inclusion in a block even harder. It is instead -better to avoid a complete filter on the transactions based on their order in -the mempool: instead we are going to perform a simpler check and then let the -block proposer rearrange them correctly when proposing the block. - -#### In-protocol protection for InnerTx - -An alternative implementation could place the protection for the inner tx in -protocol, just like the wrapper one, based on the transaction counter inside -`SignedTxData`. The check would run in `process_proposal` and the update in -`finalize_block`, just like for the wrapper transaction. This implementation, -though, shows two drawbacks: - -- it implies the need for an hard fork in case of a modification of the replay - protection mechanism -- it's not clear who's the source of the inner transaction from the outside, as - that depends on the specific code of the transaction itself. We could use - specific whitelisted txs set to define when it requires a counter (would not - work for future programmable transactions), but still, we have no way to - define which address should be targeted for replay protection (**blocking - issue**) - -#### In-protocol counter increase for InnerTx - -In the [storage counter](#storage-counters) section we mentioned the issue of -increasing the transaction counter for an inner tx even in case of failure. A -possible solution that we took in consideration and discarded was to increase -the counter from protocol in case of a failure. - -This is technically feasible since the protocol is aware of the keys modified by -the transaction and also of the results of the validity predicates (useful in -case the transaction updated more than one counter in storage). It is then -possible to recover the value and reapply the change directly from protocol. -This logic though, is quite dispersive, since it effectively splits the -management of the counter for the `InnerTx` among Wasm and protocol, while our -initial intent was to keep it completely in Wasm. - -#### Single counter in storage - -We can't use a single transaction counter in storage because this would prevent -batching. - -As an example, if a client (with a current counter in storage holding value 5) -generates two transactions to be included in the same block, signing both the -outer and the inner (default behavior of the client), it would need to generate -the following transaction counters: - -``` -[ - T1: (WrapperCtr: 5, InnerCtr: 6), - T2: (WrapperCtr: 7, InnerCtr: 8) -] -``` - -Now, the current execution model of Namada includes the `WrapperTx` in a block -first to then decrypt and execute the inner tx in the following block -(respecting the committed order of the transactions). That would mean that the -outer tx of `T1` would pass validation and immediately increase the counter to 6 -to prevent a replay attack in the same block. Now, the outer tx of `T2` will be -processed but it won't pass validation because it carries a counter with value 7 -while the ledger expects 6. - -To fix this, one could think to set the counters as follows: - -``` -[ - T1: (WrapperCtr: 5, InnerCtr: 7), - T2: (WrapperCtr: 6, InnerCtr: 8) -] -``` - -This way both the transactions will be considered valid and executed. The issue -is that, if the second transaction is not included in the block (for any -reason), than the first transaction (the only one remaining at this point) will -fail. In fact, after the outer tx has correctly increased the counter in storage -to value 6 the block will be accepted. In the next block the inner transaction -will be decrypted and executed but this last step will fail since the counter in -`SignedTxData` carries a value of 7 and the counter in storage has a value of 6. - -To cope with this there are two possible ways. The first one is that, instead of -checking the exact value of the counter in storage and increasing its value by -one, we could check that the transaction carries a counter `>=` than the one in -storage and write this one (not increase) to storage. The problem with this is -that it the lack of support for strict ordering of execution. - -The second option is to keep the usual increase strategy of the counter -(increase by one and check for strict equality) and simply use two different -counters in storage for each address. The transaction will then look like this: - -``` -[ - T1: (WrapperCtr: 5, InnerCtr: 5), - T2: (WrapperCtr: 6, InnerCtr: 6) -] -``` - -Since the order of inclusion of the `WrapperTxs` forces the same order of the -execution for the inner ones, both transactions can be correctly executed and -the correctness will be maintained even in case `T2` didn't make it to the block -(note that the counter for an inner tx and the corresponding wrapper one don't -need to coincide). - -## Wrapper-bound InnerTx - -The solution is to tie an `InnerTx` to the corresponding `WrapperTx`. By doing -so, it becomes impossible to rewrap an inner transaction and, therefore, all the -attacks related to this practice would be unfeasible. This mechanism requires -even less space in storage (only a 64 bit counter for every address signing -wrapper transactions) and only one check on the wrapper counter in protocol. As -a con, it requires communication between the signer of the inner transaction and -that of the wrapper during the transaction construction. This solution also -imposes a strict ordering on the wrapper transactions issued by a same address. - -To do so we will have to change the current definition of the two tx structs to -the following: - -```rust -pub struct WrapperTx { - /// The fee to be paid for including the tx - pub fee: Fee, - /// Used 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, - /// Lifetime of the transaction, also determines which decryption key will be used - pub expiration: DateTimeUtc, - /// Chain identifier for replay protection - pub chain_id: ChainId, - /// Transaction counter for replay protection - pub tx_counter: u64, - /// the encrypted payload - pub inner_tx: EncryptedTx, -} - -pub struct Tx { - pub code: Vec, - pub data: Option>, - pub timestamp: DateTimeUtc, - pub wrapper_commit: Option, -} -``` - -The Wrapper transaction no longer holds the inner transaction hash while the -inner one now holds a commit to the corresponding wrapper tx in the form of the -hash of a `WrapperCommit` struct, defined as: - -```rust -pub struct WrapperCommit { - pub pk: common::PublicKey, - pub tx_counter: u64, - pub expiration: DateTimeUtc, - pub chain_id: ChainId, -} -``` - -The `pk-tx_counter` couple contained in this struct, uniquely identifies a -single `WrapperTx` (since a valid tx_counter is unique given the address) so -that the inner one is now bound to this specific wrapper. The remaining fields, -`expiration` and `chain_id`, will tie these two values given their importance in -terms of safety (see the [relative](#wrappertx-checks) section). Note that the -`wrapper_commit` field must be optional because the `WrapperTx` struct itself -gets converted to a `Tx` struct before submission but it doesn't need any -commitment. - -Both the inner and wrapper tx get signed on their hash, as usual, to prevent -tampering with data. When a wrapper gets processed by the ledger, we first check -the validity of the signature, checking that none of the fields were modified: -this means that the inner tx embedded within the wrapper is, in fact, the -intended one. This last statement means that no external attacker has tampered -data, but the tampering could still have been performed by the signer of the -wrapper before signing the wrapper transaction. - -If this check (and others, explained later in the [checks](#wrappertx-checks) -section) passes, then the inner tx gets decrypted in the following block -proposal process. At this time we check that the order in which the inner txs -are inserted in the block matches that of the corresponding wrapper txs in the -previous block. To do so, we rely on an in-storage queue holding the hash of the -`WrapperCommit` struct computed from the wrapper tx. From the inner tx we -extract the `WrapperCommit` hash and check that it matches that in the queue: if -they don't it means that the inner tx has been reordered and we reject the -block. - -If this check passes then we can send the inner transaction to the wasm -environment for execution: if the transaction is signed, then at least one VP -will check its signature to spot possible tampering of the data (especially by -the wrapper signer, since this specific case cannot be checked before this step) -and, if this is the case, will reject this transaction and no storage -modifications will be applied. - -In summary: - -- The `InnerTx` carries a unique identifier of the `WrapperTx` embedding it -- Both the inner and wrapper txs are signed on all of their data -- The signature check on the wrapper tx ensures that the inner transaction is - the intended one and that this wrapper has not been used to wrap a different - inner tx. It also verifies that no tampering happened with the inner - transaction by a third party. Finally, it ensures that the public key is the - one of the signer -- The check on the `WrapperCommit` ensures that the inner tx has not been - reordered nor rewrapped (this last one is a non-exhaustive check, inner tx - data could have been tampered with by the wrapper signer) -- The signature check of the inner tx performed in Vp grants that no data of the - inner tx has been tampered with, effectively verifying the correctness of the - previous check (`WrapperCommit`) - -This sequence of controls makes it no longer possible to rewrap an `InnerTx` -which is now bound to its wrapper. This implies that replay protection is only -needed on the `WrapperTx` since there's no way to extract the inner one, rewrap -it and replay it. - -### WrapperTx checks - -In `mempool_validation` we will perform some checks on the wrapper tx to -validate it. These will involve: - -- Valid signature -- `GasLimit` is below the block gas limit (see the - [fee specs](../fee-system.mdx) for more details) -- `Fees` are paid with an accepted token and match the minimum amount required - (see the [fee specs](../fee-system.mdx) for more details) -- Valid chainId -- Valid transaction counter -- Valid expiration - -These checks can all be done before executing the transactions themselves. If -any of these fails, the transaction should be considered invalid and the action -to take will be one of the following: - -1. If the checks fail on the signature, chainId, expiration or transaction - counter, then this transaction will be forever invalid, regardless of the - possible evolution of the ledger's state. There's no need to include the - transaction in the block nor to increase the transaction counter. Moreover, - we **cannot** include this transaction in the block to charge a fee (as a - sort of punishment) because these errors may not depend on the signer of the - tx (could be due to malicious users or simply a delay in the tx inclusion in - the block) -2. If the checks fail on `Fee` or `GasLimit` the transaction should be - discarded. In theory the gas limit of a block is a Namada parameter - controlled by governance, so there's a chance that the transaction could - become valid in the future should this limit be raised. The same applies to - the token whitelist and the minimum fee required. However we can expect a - slow rate of change of these parameters so we can reject the tx (the - submitter can always resubmit it at a future time) -3. If all the checks pass validation we will include the transaction in the - block to increase the counter and charge the fee - -Note that, regarding point one, there's a distinction to be made about an -invalid `tx_counter` which could be invalid because of being old or being in -advance. To solve this last issue (counter greater than the expected one), we -have to introduce the concept of a lifetime (or timeout) for the transactions: -basically, the `WrapperTx` will hold an extra field called `expiration` stating -the maximum time up until which the submitter is willing to see the transaction -executed. After the specified time the transaction will be considered invalid -and discarded regardless of all the other checks. This way, in case of a -transaction with a counter greater than expected, it is sufficient to wait till -after the expiration to submit more transactions, so that the counter in storage -is not modified (kept invalid for the transaction under observation) and -replaying that tx would result in a rejection. - -This actually generalizes to a more broad concept. In general, a transaction is -valid at the moment of submission, but after that, a series of external factors -(ledger state, etc.) might change the mind of the submitter who's now not -interested in the execution of the transaction anymore. By introducing this new -field we are introducing a new constraint in the transaction's contract, where -the ledger will make sure to prevent the execution of the transaction after the -deadline and, on the other side, the submitter commits himself to the result of -the execution at least until its expiration. If the expiration is reached and -the transaction has not been executed the submitter can decide to submit a new, -identical transaction if he's still interested in the changes carried by it. - -In our design, the `expiration` will hold until the transaction is executed, -once it's executed, either in case of success or failure, the `tx_counter` will -be increased and the transaction will not be replayable. In essence, the -transaction submitter commits himself 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 - counter is increased -- Expiration time has passed - -The first condition satisfied will invalidate further executions of the same tx. - -Since the signer of the wrapper may be different from the one of the inner we -also need to include this `expiration` field in the `WrapperCommit` struct, to -prevent the signer of the wrapper from setting a lifetime which is in conflict -with the interests of the inner signer. Note that adding a separate lifetime for -the wrapper alone (which would require two separate checks) doesn't carry any -benefit: a wrapper with a lifetime greater than the inner would have no sense -since the inner would fail. Restricting the lifetime would work but it also -means that the wrapper could prevent a valid inner transaction from being -executed. We will then keep a single `expiration` field specifying the wrapper -tx max time (the inner one will actually be executed one block later because of -the execution mechanism of Namada). - -To prevent the signer of the wrapper from submitting the transaction to a -different chain, the `ChainId` field should also be included in the commit. - -Finally, in case the transaction run out of gas (based on the provided -`GasLimit` field of the wrapper) we don't need to take any action: by this time -the transaction counter will have already been incremented and the tx is not -replayable anymore. In theory, we don't even need to increment the counter since -the only way this transaction could become valid is a change in the way gas is -accounted, which might require a fork anyway, and consequently a change in the -required `ChainId`. However, since we can't tell the gas consumption before the -inner tx has been executed, we cannot anticipate this check. - -All these checks are also run in `process_proposal` with an addition: validators -also check that the wrapper signer has enough funds to pay the fee. This check -should not be done in mempool because the funds available for a certain address -are variable in time and should only be checked at block inclusion time. If any -of the checks fail here, the entire block is rejected forcing a new Tendermint -round to begin (see a better explanation of this choice in the -[relative](#block-rejection) section). - -The `expiration` parameter also justifies that the check on funds is only done -in `process_proposal` and not in mempool. Without it, the transaction could be -potentially executed at any future moment, possibly going against the mutated -interests of the submitter. With the expiration parameter, now, the submitter -commits himself to accept the execution of the transaction up to the specified -time: it's going to be his responsibility to provide a sensible value for this -parameter. Given this constraint the transaction will be kept in mempool up -until the expiration (since it would become invalid after that in any case), to -prevent the mempool from increasing too much in size. - -This mechanism can also be applied to another scenario. Suppose a transaction -was not propagated to the network by a node (or a group of colluding nodes). -Now, this tx might be valid, but it doesn't get inserted into a block. Without -an expiration, if the submitter doesn't submit any other transaction (which gets -included in a block to increase the transaction counter), this tx can be -replayed (better, applied, since it was never executed in the first place) at a -future moment in time when the submitter might not be willing to execute it any -more. - -### WrapperCommit - -The fields of `WrapperTx` not included in `WrapperCommit` are at the discretion -of the `WrapperTx` producer. These fields are not included in the commit because -of one of these two reasons: - -- They depend on the specific state of the wrapper signer and cannot be forced - (like `fee`, since the wrapper signer must have enough funds to pay for those) -- They are not a threat (in terms of replay attacks) to the signer of the inner - transaction in case of failure of the transaction - -In a certain way, the `WrapperCommit` not only binds an `InnerTx` no a wrapper, -but effectively allows the inner to control the wrapper by requesting some -specific parameters for its creation and bind these parameters among the two -transactions: this allows us to apply the same constraints to both txs while -performing the checks on the wrapper only. - -### Transaction creation process - -To craft a transaction, the process will now be the following (optional steps -are only required if the signer of the inner differs from that of the wrapper): - -- (**Optional**) the `InnerTx` constructor request, to the wrapper signer, his - public key and the `tx_counter` to be used -- The `InnerTx` is constructed in its entirety with also the `wrapper_commit` - field to define the constraints of the future wrapper -- The produced `Tx` struct get signed over all of its data (with `SignedTxData`) - producing a new struct `Tx` -- (**Optional**) The inner tx produced is sent to the `WrapperTx` producer - together with the `WrapperCommit` struct (required since the inner tx only - holds the hash of it) -- The signer of the wrapper constructs a `WrapperTx` compliant with the - `WrapperCommit` fields -- The produced `WrapperTx` gets signed over all of its fields - -Compared to a solution not binding the inner tx to the wrapper one, this -solution requires the exchange of 3 messages (request `tx_counter`, receive -`tx_counter`, send `InnerTx`) between the two signers (in case they differ), -instead of one. However, it allows the signer of the inner to send the `InnerTx` -to the wrapper signer already encrypted, guaranteeing a higher level of safety: -only the `WrapperCommit` struct should be sent clear, but this doesn't reveal -any sensitive information about the inner transaction itself. \ No newline at end of file