Skip to main content

View-Call Authentication

User impersonation on Ethereum and other "transparent EVMs" isn't a problem because everybody can see all data. However, the Sapphire confidential EVM prevents contracts from revealing confidential information to the wrong party (account or contract)—for this reason we cannot allow arbitrary impersonation of any msg.sender.

In Sapphire, you need to consider the following types of contract calls:

  1. Contract to contract calls (also known as internal calls)

    msg.sender is set to the address corresponding to the caller function. If a contract calls another contract in a way which could reveal sensitive information, the calling contract must implement access control or authentication.

  2. Unauthenticted view calls (queries using eth_call)

    eth_call queries used to invoke contract functions will always have the msg.sender parameter set to address(0x0) on Sapphire. This is regardless of any from overrides passed on the client side for simulating the query.

    note

    Calldata end-to-end encryption has nothing to do with authentication. Although the calls may be unauthenticated they can still be encrypted, and the other way around!

  3. Authenticated view calls (via SIWE token)

    Developer authenticates the view call explicitly by deriving a message sender from the SIWE token. This token is provided as a separate parameter to the contract function. The derived address can then be used for authentication in place of msg.sender. Otherwise, such view call behaves the same way as the unauthenticated view calls above and built-in msg.sender is address(0x0). This approach is most appropriate for frontend dApps.

  4. Authenticated view calls (via signed queries)

    EIP-712 defines a format for signing view calls with the keypair of your Ethereum account. Sapphire will validate such signatures and automatically set the msg.sender parameter in your contract to the address of the signing account. This method is most appropriate for backend services because frontend applications would require user interaction each time.

  5. Transactions (authenticated by signature)

    When a transaction is submitted it is signed by a keypair (thus costs gas and can make state updates) and the msg.sender will be set to the address of the signing account.

How Sapphire Executes Contract Calls

Let's see how Sapphire executes contract calls for each call variant presented above. Consider the following Solidity code:

contract Example {
address _owner;
constructor () {
_owner = msg.sender;
}
function isOwner() public view returns (bool) {
return msg.sender == _owner;
}
}

In the sample above, assuming we're calling from the same contract or account which created the contract, calling isOwner will return:

  1. true, if called via the contract which created it
  2. false, for unauthenticated eth_call
  3. false, since the contract has no SIWE implementation
  4. true, for signed view call using the wrapped client (Go, Python) with signer attached
  5. true, if called via transaction

Now that we've covered basics, let's look more closely at the authenticated view calls. These are crucial for building confidential smart contracts on Sapphire.

Authenticated view calls

Consider this slightly extended version of the contract above. Only the owner is allowed to store and retrieve secret message:

contract MessageBox {
address private _owner;
string private _message;

modifier onlyOwner() {
if (msg.sender != _owner) {
revert("not allowed");
}
_;
}
constructor() {
_owner = msg.sender;
}

function getSecretMessage() external view onlyOwner returns (string memory) {
return _message;
}

function setSecretMessage(string calldata message) external onlyOwner {
_message = message;
}
}

via SIWE token

SIWE stands for "Sign-In with Ethereum" and is formally defined in EIP-4361. The initial use case for SIWE involved using your Ethereum account as a form of authentication for off-chain services (providing an alternative to user names and passwords). The MetaMask wallet quickly adopted the standard and it became a de-facto login mechanism in the Web3 world. An informative pop-up for logging into a SIWE-enabled website looks like this:

MetaMask Log-In confirmation

After a user agrees by signing the SIWE login message above, the signature is verified by the website backend or by a 3rd party single sign-on service. This is done only once per session—during login. A successful login generates a token that is used for the remainder of the session.

In contrast to transparent EVM chains, Sapphire simplifies dApp design, improves trust, and increases the usability of SIWE messages through extending message parsing and verification to on-chain computation. This feature (unique to Sapphire) removes the need to develop and maintain separate dApp backend services just for SIWE authentication. Let's take a look at an example authentication flow:

SIWE authentication flow on Sapphire

Consider the MessageBox contract from above, and let's extend it with SiweAuth:

import {SiweAuth} from "@oasisprotocol/sapphire-contracts/contracts/auth/SiweAuth.sol";

contract MessageBox is SiweAuth {
address private _owner;
string private _message;

modifier onlyOwner(bytes memory token) {
if (msg.sender != _owner && authMsgSender(token) != _owner) {
revert("not allowed");
}
_;
}

constructor(string memory domain) SiweAuth(domain) {
_owner = msg.sender;
}

function getSecretMessage(bytes memory token) external view onlyOwner(token) returns (string memory) {
return _message;
}

function setSecretMessage(string calldata message) external onlyOwner(bytes("")) {
_message = message;
}
}

We made the following changes:

  1. In the constructor, we need to define the domain name where the dApp frontend will be deployed. This domain is included inside the SIWE log-in message and is verified by the user-facing wallet to make sure they are accessing the contract from a legitimate domain.
  2. The onlyOwner modifier is extended with an optional bytes memory token parameter and is considered in the case of invalid msg.sender value. The same modifier is used for authenticating both SIWE queries and the transactions.
  3. getSecretMessage was extended with the bytes memory token session token.

On the client side, the code running inside a browser needs to make sure that the session token for making authenticated calls is valid. If not, the browser requests a wallet to sign a log-in message and fetch a fresh session token.

import {SiweMessage} from 'siwe';
import { ethers } from 'hardhat'

let token = '';

async function getSecretMessage(): Promise<Message> {
const messageBox = await ethers.getContractAt('MessageBox', '0x5FbDB2315678afecb367f032d93F642f64180aa3');

if (token == '') { // Stored in browser session.
const domain = await messageBox.domain();
const siweMsg = new SiweMessage({
domain,
address: addr, // User's selected account address.
uri: `http://${domain}`,
version: "1",
chainId: 0x5afe, // Sapphire Testnet
}).toMessage();
const sig = ethers.Signature.from((await window.ethereum.getSigner(addr)).signMessage(siweMsg));
token = await messageBox.login(siweMsg, sig);
}

return messageBox.getSecretMessage(token);
}

A few words about the SIWE domain parameter

During contract deployment you have to provide the domain where the web content of your dApp will be hosted at. MetaMask will check whether the domain shown in the user's browser window matches the one provided in the SIWE message used for logging in and then warn the user if there is a discrepancy. On the other hand—if the SIWE message is forged to match the (exploited) web site domain, the on-chain SiweAuth message will fail validation and prevent the user from obtaining a valid token.

When deploying your contract, the provided domain should include the host chunk of the URI, including any subdomains, and optionally the port. No scheme (e.g. http://, https://) or path (e.g. /my-app/login) should be included. If you wish to enforce authentication only on a specific port, provide it alongside the domain, e.g. mydomain.com:12345. Otherwise, MetaMask will consider any port valid.

The visibility of _domain in SiweAuth is internal. By default, a public getter is implemented, so a web app can automatically obtain a domain name when generating the SIWE message. No setters are provided in keeping the domain immutable. If needed, feel free to implement a setter in your contract with appropriate authentication mechanisms (e.g. onlyOwner modifier). For traceability, we also suggest to emit an event when the domain is changed as transactions may be encrypted.

Example: Starter project

To see a running example of the TypeScript SIWE code including the Hardhat tests, Node.js and the browser, check out the official Oasis demo-starter project. The SIWE authentication is implemented on the backend as a Hardhat task, in unit tests, and on the frontend within the Web3 Auth provider code.

Sapphire TypeScript wrapper?

While the Sapphire TypeScript wrapper offers convenient end-to-end encryption for contract calls and transactions, using the TypeScript wrapper is required for SIWE security if you trust your Web3 endpoint. The token generation occurs inside the Sapphire's TEE and the communication with your Web3 endpoint is secured via HTTPS.

via signed queries

EIP-712 proposed a method to show data to the user in a structured fashion so they can verify it before signing. In the browser however, apps requiring signed view calls would trigger user interaction with their wallet each time—sometimes even multiple times per page—which is bad UX that frustrates users. Backend services on the other hand often have direct access to an Ethereum wallet (e.g. a secret key stored in the environment variable) without needing user interaction. This is possible because a backend service connects to a trusted site and executes trusted code, so it's fine to sign the necessary view calls non interactively.

The Sapphire wrappers for Go and Python will sign any view call you make to a contract deployed on Sapphire using the aforementioned EIP-712. Suppose we want to store the private key of an account used to sign the view calls inside a PRIVATE_KEY environment variable. The following snippets demonstrate how to trigger signed queries without any changes to the original MessageBox contract from above.

Wrap the existing Ethereum client by calling the WrapClient() helper and provide the signing logic. Then, all subsequent view calls will be signed. For example:

import (
"context"
"crypto/ecdsa"

"github.com/ethereum/go-ethereum/accounts/abi/bind"
"github.com/ethereum/go-ethereum/common"
"github.com/ethereum/go-ethereum/crypto"
"github.com/ethereum/go-ethereum/ethclient"

sapphire "github.com/oasisprotocol/sapphire-paratime/clients/go"

messageBox "demo-starter/contracts/message-box"
)

func GetC10lMessage() (string, error) {
client, err = ethclient.Dial("https://testnet.sapphire.oasis.io")
if err != nil {
return "", err
}

sk, err = crypto.HexToECDSA(os.Getenv("PRIVATE_KEY"))
addr := crypto.PubkeyToAddress(*sk.Public().(*ecdsa.PublicKey))

wrappedClient, err := sapphire.WrapClient(c.Client, func(digest [32]byte) ([]byte, error) {
return crypto.Sign(digest[:], sk)
})
if err != nil {
return "", fmt.Errorf("unable to wrap backend: %v", err)
}

mb, err := messageBox.NewMessageBox(common.HexToAddress("0x5FbDB2315678afecb367f032d93F642f64180aa3"), wrappedClient)
if err != nil {
return "", fmt.Errorf("Unable to get instance of contract: %v", err)
}

msg, err := mb.GetSecretMessage(&bind.CallOpts{From: addr}) // Don't forget to pass callOpts!
if err != nil {
return "", fmt.Errorf("failed to retrieve message: %v", err)
}

return msg, nil
}
Example: Oasis starter in Go

To see a running example of the Go code including the end-to-end encryption and signed queries check out the official Oasis starter project for Go.

tip

If your smart contract needs to support view calls from both the frontend and the backend, then take the SIWE approach. The backend implementation then depends on your programming language:

  • Go and Python: Pass an empty string as a token parameter to your smart contract and let the wrapper sign the view call using EIP-712. Since msg.sender will be defined, the isOwner modifier will pass just fine.
  • TypeScript: Recycle the frontend client-side code from above to generate the SIWE message, perform the authentication and pass it in the view call. You can check out the demo-starter's Hardhat task and unit tests for a working example.