When a blockchain user signs a transaction, they usually do so with an expected outcome in mind. However, many blockchains do not offer a way to specify the intent of a given signature at the protocol level. That means what the user expects to happen and what actually happens are not necessarily going to be the same thing, especially when it comes to malicious or buggy smart contracts.

On Neo N3, transaction signatures carry intent in the form of a witness scope. The witness scope defines the rules behind how a signature can be used. In the latest Neo update, v3.1.0, the new Rules witness scope was introduced. The update prompted a new article from Neo SPCC, offering a primer on Neo N3 transactions, CheckWitness, and Witness Scopes, including the new Rules functionality.

Witness checks

When building a smart contract, developers will usually need a way to add authentication to certain operations. For example, if you create a NEP-17 token, you would need a way to prevent one user from transferring the assets of another user. In other words, if a user wants to transfer their tokens, they need to provide a signature that the contract can authenticate.

This authentication is handled by a function called CheckWitness in Neo smart contracts. As the name implies, it checks that the transaction has been signed by the appropriate witness. In the case of a NEP-17 token transfer, the contract would take the account specified as the sender, then check that the same sender has witnessed (signed) this transaction. If it hasn’t, then the check doesn’t pass and the transaction fails.

Though the CheckWitness function is unique to Neo, this general principle is a natural part of every blockchain. It is why we use public key cryptography in the first place—Bitcoin wouldn’t be very useful if other people could spend your Bitcoin. However, checking a signature alone isn’t necessarily sufficient to safeguard users.

The problem with leaving a signature completely open to interpretation is that a transaction may have a lot of unintended side effects. This is especially true when it comes to smart contracts that can invoke other smart contracts. Invoking a faulty or malicious smart contract could cause it to attempt to invoke other contracts, to steal token balances for example. Without a mechanism that limits the scope of a signature, signing such a transaction means unwittingly authorizing the transfers.

In Ethereum, as this problem was left unsolved at the protocol level, it had to be solved on the application layer. The ERC-20 token standard features the transferFrom method, which allows a contract to transfer assets out of a user’s account. To prevent it from being abused, ERC-20 tokens also implement the approve method, which lets a user specify what contract can use transferFrom and the maximum amount of tokens that can be transferred.

This safeguard in ERC-20 solves the same basic case we noted above; a malicious contract that attempts to transfer your tokens out of your account will always fail unless for some reason the user has given approval to the malicious contract. Similar standards for other contract use cases may implement similar in-contract solutions, but beyond those, transaction security can be summed up as “be very careful about what you sign.”

Signature scopes

Earlier, we noted that Neo contracts use a function called CheckWitness, which looks at the list of signers for a transaction and checks that it matches the account in question for whatever operation is being called. It does something else too; if it finds a matching signer, it moves on to check the witness scope. These scopes define which contracts the signer has approved themselves as a witness for.

When signing a transaction, a user can use the available scopes to specify exactly how that signature can be used. If a certain contract falls outside the scope of a signature, any CheckWitness calls within it will automatically fail. As of Neo v3.1.0, there are 6 scopes:

  • Global
  • None
  • CalledByEntry
  • CustomContracts
  • CustomGroups
  • Rules (since 3.1.0)

Global, None, and CalledByEntry

Global is the most unsafe scope, because essentially it removes the witness scope check from the equation. When a user signs a transaction with the global scope, CheckWitness calls will pass anywhere it is called in the lifetime of the transaction, no matter which contract is being invoked. If the transaction invokes a malicious contract which tries to then call a token contract to transfer the user’s assets, the global scope gives authorization for this action.

On the other side of the spectrum is the None scope, meaning no authorization for signature use is given. It’s usage is quite limited, as in many situations, if no authorization is needed for a certain contract method, no signature is needed at all. But as Neo SPCC explains, there is a niche application for it when it comes to senders.

The transaction sender is always the first signer of a transaction and is the one who pays transaction fees. With the None scope, a signer can pay for transaction fees without giving any further authorization for use of the signature. This is used in the N3 oracle subsystem, and could also play a role for dApps that wish to subsidize transaction fees for users.

CalledByEntry is the safest signature scope, ideal to use as the default for most basic cases. With this scope, only contracts that are called by the entry script are given permission to use the signature.

The user can always be aware of what contracts are being called by the entry script because they are the one who creates the transaction. If for some reason one of those contracts calls another contract, any witness check in the second contract would fail, since this contract was not explicitly called in the entry script.

Scope protection demo

Let’s demonstrate this in action with a simple malicious smart contract. This contract has one function for users to call. When the user invokes the freeCookies function, it calls the GAS token contract and attempts to transfer the GAS balance of the user to the owner of the contract.

When the malicious contract was invoked by Bob using the None and CalledByEntry scopes, nothing happens (aside from the GAS paid to send the transaction). This is because these scopes don’t authorize the signature to be used for the witness check in the GAS contract’s transfer method.

Changing the signature scope to Global emulates blockchains that offer no constraints for signature usage. As expected, this gives the malicious contract the authorization it needs to transfer the tokens, and the contract successfully steals Bob’s GAS balance.

CustomContracts & CustomGroups

With the basic scopes out of the way, we can move on to CustomContracts and CustomGroups. CustomContracts is simplest to explain: users that select this scope will select one or more contracts by script hash and permit CheckWitness calls within them. This is primarily used in multi-contract invokes or dApps that use multiple contracts and require witness checks in both. For our malicious contract, using the CustomContracts scope and adding the GAS token script hash would allow the theft to work, so as with the global scope, allowing contracts should be done with caution.

Likewise, CustomGroups enables a selection of authorized contracts. Groups are defined as signed part of contract manifests, in accordance with the NEP-15 standard, and are represented by a single public key. The key can be used to authorize witness use for numerous contracts without needing to list each one directly as in CustomContracts.

Rules

Finally, with all this context out of the way, we can tackle the latest upgrade. Added in Neo v3.1.0, the new Rules witness scope allows other scopes to be combined in different ways, giving even more ways for users to express intent in transactions.

The Rules scope was designed to fix edge cases with CustomContracts and CustomGroups. In particular, there was nothing to specify where in the invocation stack the contracts/groups can be called. Neo SPCC explains:

“This leaves some potential for reentrance attack, especially given the NEP-11/NEP-17 ”onNEPXXPayment” functionality. Trusted (and witnessed) contract can call another contract which in turn can call a trusted contract again, and it will get a valid witness even though it was never intended to.”

Using this scope means establishing a set of rules with conditions to be matched. A list of these conditions was provided by Neo SPCC:

  • Boolean: true or false, mostly useful for testing or emulating Global/None scopes
  • Not: inverting nested condition
  • And: matching a whole set of nested conditions (up to 16 of them)
  • Or: matching one of conditions from nested set (also up to 16)
  • ScriptHash: contains a hash to compare with script that is being currently executed (similar to CustomContracts)
  • Group: contains a key identifying a group to compare with groups of the currently executing script (similar to CustomGroups)
  • CalledByEntry: evaluates to true if the script is an entry script or one called directly by it (like CalledByEntry scope)
  • CalledByContract: contains a hash to compare with the caller script hash
  • CalledByGroup: contains a key identifying a group to compare with the groups of the caller script

Looking at the options and how they can be combined, you can imagine how more complex signature scoping can be achieved. Further, up to 16 rules can be stacked, some of which support nesting down to two levels.

Using the new functionally, edge cases as noted earlier can be easily addressed:

“The case with reentrancy can now be easily solved by combining (And) checks for current group (Group) and calling group (CalledByGroup) or hash (CalledByContract) in a single rule.”

Rules and other scopes make the Neo N3 witness system one of the most expressive solutions to the problems surrounding signature use in the blockchain industry.

More information about Neo N3 and witness scopes can be found in the article by Neo SPCC:
https://neospcc.medium.com/thou-shalt-check-their-witnesses-485d2bf8375d