aztec-nr - noir_aztec::authwit::auth

Global IS_VALID_SELECTOR

pub global IS_VALID_SELECTOR: Field;

Authentication witness helper library

Authentication Witness is a scheme for authenticating actions on Aztec, so users can allow third-parties (e.g. protocols or other users) to execute an action on their behalf.

This library provides helper functions to manage such witnesses. The authentication witness, is some "witness" (data) that authenticates a message_hash. The simplest example of an authentication witness, is a signature. The signature is the "evidence", that the signer has seen the message, agrees with it, and has allowed it. It does not need to be a signature. It could be any kind of "proof" that the message is allowed. Another proof could be knowing some kind of secret, or having some kind of "token" that allows the message.

The message_hash is a hash of the following structure: hash(consumer, chain_id, version, inner_hash)

While the inner_hash could be anything, such as showing you signed a specific message, it will often be a hash of the "action" to approve, along with who made the call. As part of this library, we provide a few helper functions to deal with such messages.

For example, we provide helper function that is used for checking that the message is an encoding of the current call. This can be used to let some contract "allow" another contract to act on its behalf, as long as it can show that it is acting on behalf of the contract.

If we take a case of allowing a contract to transfer tokens on behalf of an account, the inner_hash can be derived as: inner_hash = hash(caller, "transfer", hash(to, amount))

Where the caller would be the address of the contract that is trying to transfer the tokens, and to and amount the arguments for the transfer.

Note that we have both a caller and a consumer, the consumer will be the contract that is consuming the message, in the case of the transfer, it would be the Token contract itself, while the caller, will be the actor that is allowed to transfer the tokens.

The authentication mechanism works differently in public and private contexts. In private, we recall that everything is executed on the user's device, so we can use oracles to "ask" the user (not contract) for information. In public we cannot do this, since it is executed by the sequencer (someone else). Therefore we can instead use a "registry" to store the messages that we have approved.

A simple example would be a "token" that is being "pulled" from one account into another. We will first outline how this would look in private, and then in public later.

Say that a user Alice wants to deposit some tokens into a DeFi protocol (say a DEX). Alice would make a deposit transaction, that she is executing using her account contract. The account would call the DeFi contract to execute deposit, which would try to pull funds from the Token contract. Since the DeFi contract is trying to pull funds from an account that is not its own, it needs to convince the Token contract that it is allowed to do so.

This is where the authentication witness comes in The Token contract computes a message_hash from the transfer call, and then asks Alice Account contract to verify that the DeFi contract is allowed to execute that call.

Alice Account contract can then ask Alice if she wants to allow the DeFi contract to pull funds from her account. If she does, she will sign the message_hash and return the signature to the Alice Account which will validate it and return success to the Token contract which will then allow the DeFi contract to pull funds from Alice.

To ensure that the same "approval" cannot be used multiple times, we also compute a nullifier for the authentication witness, and emit it from the Token contract (consumer).

Note that we can do this flow as we are in private were we can do oracle calls out from contracts.

Person Contract Contract Contract Alice Alice Account Token DeFi | | | | | Defi.deposit(Token, 1000) | | |----------------->| | | | | deposit(Token, 1000) | | |---------------------------------------->| | | | | | | | transfer(Alice, Defi, 1000) | | |<---------------------| | | | | | | Check if Defi may call transfer(Alice, Defi, 1000) | |<-----------------| | | | | | | Please give me AuthWit for DeFi | | | calling transfer(Alice, Defi, 1000) | | |<-----------------| | | | | | | | | | | | AuthWit for transfer(Alice, Defi, 1000) | |----------------->| | | | | AuthWit validity | | | |----------------->| | | | | | | | throw if invalid AuthWit | | | | | | | emit AuthWit nullifier | | | | | | | transfer(Alice, Defi, 1000) | | | | | | | | | | | | success | | | |--------------------->| | | | | | | | | | | | deposit(Token, 1000) | | | | | | | |

If we instead were in public, we cannot do the same flow. Instead we would use an authentication registry to store the messages that we have approved.

To approve a message, Alice Account can make a set_authorized call to the registry, to set a message_hash as authorized. This is essentially a mapping from message_hash to true for Alice Contract. Every account has its own map in the registry, so Alice cannot approve a message for Bob.

The Token contract can then try to "spend" the approval by calling consume on the registry. If the message was approved, the value is updated to false, and we return the success flag. For more information on the registry, see main.nr in auth_registry_contract.

Person Contract Contract Contract Contract Alice Alice Account Registry Token DeFi | | | | | | Registry.set_authorized(..., true) | | | |----------------->| | | | | | set_authorized(..., true) | | | |------------------->| | | | | | | | | | set authorized to true | | | | | | | | | | | | | Defi.deposit(Token, 1000) | | | |----------------->| | | | | | deposit(Token, 1000) | | | |-------------------------------------------------------------->| | | | | | | | | transfer(Alice, Defi, 1000) | | | | |<---------------------| | | | | | | | | Check if Defi may call transfer(Alice, Defi, 1000) | | |<------------------| | | | | | | | | throw if invalid AuthWit | | | | | | | | | | | | | | set authorized to false | | | | | | | | | | | | | | | AuthWit validity | | | | |------------------>| | | | | | | | | | | transfer(Alice, Defi, 1000) | | | |<-------------------->| | | | | | | | | | success | | | | |--------------------->| | | | | | | | | | deposit(Token, 1000) | | | | |

--- FAQ --- Q: Why are we using a success flag of poseidon2_hash_bytes("IS_VALID()") instead of just returning a boolean? A: We want to make sure that we don't accidentally return true if there is a collision in the function selector. By returning a hash of IS_VALID(), it becomes very unlikely that there is both a collision and we return a success flag.

Q: Why are we using static calls? A: We are using static calls to ensure that the account contract cannot re-enter. If it was a normal call, it could make a new call and do a re-entry attack. Using a static ensures that it cannot update any state.

Q: Would it not be cheaper to use a nullifier instead of updating state in public? A: At a quick glance, a public state update + nullifier is 96 bytes, but two state updates are 128, so it would be cheaper to use a nullifier, if this is the way it would always be done. However, if both the approval and the consumption is done in the same transaction, then we will be able to squash the updates (only final tx state diff is posted to DA), and now it is cheaper.

Q: Why is the chain id and the version part of the message hash? A: The chain id and the version is part of the message hash to ensure that the message is only valid on a specific chain to avoid a case where the same message could be used across multiple chains.