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, there are four types of contract calls:
- Contract to contract calls (also known as internal calls)
- Unauthenticted view calls (queries using
eth_call
) - Authenticated view calls (signed queries)
- Transactions (authenticated by signature)
Intra-contract calls always set msg.sender
appropriately, if a contract calls
another contract in a way which could reveal sensitive information, the calling
contract must implement access control or authentication.
By default all eth_call
queries used to invoke contract functions have the
msg.sender
parameter set to address(0x0)
. In contrast, authenticated calls are
signed by a keypair and will have the msg.sender
parameter correctly initialized
(more on that later). Also, 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 signing account.
Sapphire Wrapper
The @oasisprotocol/sapphire-paratime Ethereum provider wrapper
sapphire.wrap
function will automatically end-to-end encrypt calldata when
interacting with contracts on Sapphire, this is an easy way to ensure the
calldata of your dApp transactions remain confidential - although the from
,
to
, and gasprice
parameters are not encrypted.
Although the calls may be unauthenticated, they can still be encrypted!
However, if the Sapphire wrapper has been attached to a signer then subsequent
view calls via eth_call
will request that the user sign them (e.g. a
MetaMask popup), these are called signed queries meaning msg.sender
will be
set to the signing account and can be used for authentication or to implement
access control. This may add friction to the end-user experience and can result
in frequent pop-ups requesting they sign queries which wouldn't normally require
any interaction on Transparent EVMs.
Let's see how Sapphire interprets different contract calls. Suppose 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:
false
, foreth_call
false
, withsapphire.wrap
but without an attached signertrue
, withsapphire.wrap
and an attached signertrue
, if called via the contract which created ittrue
, if called via transaction
Caching Signed Queries
When using signed queries the blockchain will be queried each time, however the Sapphire wrapper will cache signatures for signed queries with the same parameters to avoid asking the user to sign the same thing multiple times.
Behind the scenes the signed queries use a "leash" to specify validity conditions
so the query can only be performed within a block and account nonce
range.
These parameters are visible in the EIP-712 popup signed by the user. Queries
with the same parameters will use the same leash.
Daily Sign-In with EIP-712
One strategy which can be used to reduce the number of transaction signing prompts when a user interacts with contracts via a dApp is to use EIP-712 to "sign-in" once per day (or per-session), in combination with using two wrapped providers:
- Provider to perform encrypted but unauthenticated view calls
- Another provider to perform encrypted and authenticated transactions (or view calls)
- The user will be prompted to sign each action.
The two-provider pattern, in conjunction with a daily EIP-712 sign-in prompt ensures all transactions are end-to-end encrypted and the contract can authenticate users in view calls without frequent annoying popups.
The code sample below uses an authenticated
modifier to verify the sign-in:
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
struct SignatureRSV {
bytes32 r;
bytes32 s;
uint256 v;
}
contract SignInExample {
bytes32 public constant EIP712_DOMAIN_TYPEHASH = keccak256("EIP712Domain(string name,string version,uint256 chainId,address verifyingContract)");
string public constant SIGNIN_TYPE = "SignIn(address user,uint32 time)";
bytes32 public constant SIGNIN_TYPEHASH = keccak256(bytes(SIGNIN_TYPE));
bytes32 public immutable DOMAIN_SEPARATOR;
constructor () {
DOMAIN_SEPARATOR = keccak256(abi.encode(
EIP712_DOMAIN_TYPEHASH,
keccak256("SignInExample.SignIn"),
keccak256("1"),
block.chainid,
address(this)
));
}
struct SignIn {
address user;
uint32 time;
SignatureRSV rsv;
}
modifier authenticated(SignIn calldata auth)
{
// Must be signed within 24 hours ago.
require( auth.time > (block.timestamp - (60*60*24)) );
// Validate EIP-712 sign-in authentication.
bytes32 authdataDigest = keccak256(abi.encodePacked(
"\x19\x01",
DOMAIN_SEPARATOR,
keccak256(abi.encode(
SIGNIN_TYPEHASH,
auth.user,
auth.time
))
));
address recovered_address = ecrecover(
authdataDigest, uint8(auth.rsv.v), auth.rsv.r, auth.rsv.s);
require( auth.user == recovered_address, "Invalid Sign-In" );
_;
}
function authenticatedViewCall(
SignIn calldata auth,
... args
)
external view
authenticated(auth)
returns (bytes memory output)
{
// Use `auth.user` instead of `msg.sender`!
}
}
With the above contract code deployed, let's look at the frontend dApp and how it can request the user to sign-in using EIP-712. You may wish to add additional parameters which are authenticated such as the domain name. The following code example uses Ethers:
const time = new Date().getTime();
const user = await eth.signer.getAddress();
// Ask user to "Sign-In" every 24 hours.
const signature = await eth.signer.signTypedData({
name: "SignInExample.SignIn",
version: "1",
chainId: import.meta.env.CHAINID,
verifyingContract: await contract.getAddress()
}, {
SignIn: [
{ name: 'user', type: "address" },
{ name: 'time', type: 'uint32' },
]
}, {
user,
time: time
});
const rsv = ethers.Signature.from(signature);
const auth = {user, time, rsv};
// The `auth` variable can then be cached.
// Then in the future, authenticated view calls can be performed by
// passing auth without further user interaction authenticated data.
await contract.authenticatedViewCall(auth, ...args);