Building the Contracts
Project Setup
Make a new folder for the project called zksync-webauthn
and navigate to that folder in your terminal:
mkdir zksync-webauthn
cd zksync-webauthn
We can start by creating the contracts for the smart account, paymaster, and NFT.
npx zksync-cli create contracts --template hardhat_solidity --project contracts
The CLI will prompt you to enter a private key for deploying. Enter the private key below for a pre-configured rich wallet:
? Private key of the wallet responsible for deploying contracts (optional)
0x7726827caac94a7f9e1b160f7ea819f172f7b6f9d2a97f992c38edeab82d4110
Once that is done, move into the contracts
folder
and delete the template contracts, scripts, and tests:
cd contracts
rm -rf ./contracts/*
rm -rf ./deploy/*
rm -rf ./test/*
Contracts
Open the zksync-webauthn/contracts
folder in your preferred IDE.
We will be creating 4 contracts:
GeneralPaymaster.sol
: a general paymaster contract to sponsor transactions.MyNFT.sol
: a generic NFT contract.AAFactory.sol
: a factory contract to deploy new instances of smart accounts.Account.sol
: the contract for the smart account.
Paymaster Contract
Create a new file in the contracts/contracts
folder called GeneralPaymaster.sol
.
touch contracts/GeneralPaymaster.sol
This is a basic paymaster contract that allows us to sponsor transactions for users of our app.
NFT Contract
Create another file in the contracts/contracts
folder called MyNFT.sol
.
touch contracts/MyNFT.sol
This is a generic NFT contract that mints svg images of Zeek using different colors. We will use this to test interacting with smart contracts using WebAuthn.
AA Factory
Create a file in the contracts/contracts
folder called AAFactory.sol
.
touch contracts/AAFactory.sol
This contract is a factory contract responsbile for deploying new instances of the Account.sol
contract.
Smart Contract Account
Finally, create a file in the contracts/contracts
folder called Account.sol
.
touch contracts/Account.sol
The smart account contract is where we perform the WebAuthn signature validation.
Understanding WebAuthn Signature Validation
In the Account.sol
contract, the _validateTransaction
function checks to see if there is a valid secp256k1 signature from the account owner,
just like the example in the Native AA Multisig tutorial.
If the transaction is not signed by the standard account owner, the next check is to see if the transaction contains a valid WebAuthn signature.
if (isValidSignature(txHash, _transaction.signature) == EIP1271_SUCCESS_RETURN_VALUE) {
magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
} else {
bool valid = validateWebAuthnSignature(_transaction.signature, txHash);
if (valid) {
magic = ACCOUNT_VALIDATION_SUCCESS_MAGIC;
} else {
magic = bytes4(0);
}
}
The contract contains a state variable r1Owner
to store the public key of the WebAuthn account that the owner wants to authorize.
The updateR1Owner
function can update the value of the r1Owner
.
// the secp256r1 public key of the owner
bytes public r1Owner;
function updateR1Owner(bytes memory _r1Owner) external onlySelf {
r1Owner = _r1Owner;
}
To validate the signature, the _validateWebAuthnSignature
function
first extracts the authenticatorData
, clientData
, and rs
values from the decoded signature.
It's important to note that the WebAuthn authentication process doesn't just sign the transaction data we send as a challenge.
The WebAuthn authentication process returns three pieces of information that we need for the validation process:
the WebAuthn signature, the authenticatorData
, and the clientData
.
The extractChallengeFromClientData
function extracts the WebAuthn challenge from the clientData
.
Note that the extractChallengeFromClientData
function relies on the specific implementation of how the challenge is created
in the frontend section of the tutorial in order to work.
The extracted challenge and the transaction hash are checked to ensure they are equal. If the values are equal, this means that the exact transaction data that is submitted to the contract matches the transaction data signed by the WebAuthn account.
Next, the _validateWebAuthnSignature
function checks for the malleability of the signature,
and checks to see if the WebAuthn user flags are set.
We will ensure that the signature conforms to these checks in the frontend section.
Finally, it calls the callVerifier
function to call the P256Verify
precompile.
function _validateWebAuthnSignature(
bytes32 txHash,
bytes memory webauthnSignature,
bytes32[2] memory pubKey
) public view returns (bool valid) {
(bytes memory authenticatorData, bytes memory clientData, bytes32[2] memory rs) = _decodeWebAuthnSignature(
webauthnSignature
);
// challenge is a base64url
string memory challenge = extractChallengeFromClientData(clientData);
string memory encodedHash = txHashToBase64Url(txHash);
if (keccak256(abi.encodePacked(challenge)) != keccak256(abi.encodePacked(encodedHash))) {
return false;
}
// malleability check
if (rs[1] > lowSmax) {
return false;
}
// check if the webauthn user flags are set
if (authenticatorData[32] & AUTH_DATA_MASK != AUTH_DATA_MASK) {
return false;
}
bytes32 clientDataHash = sha256(clientData);
bytes32 message = sha256(bytes.concat(authenticatorData, clientDataHash));
valid = callVerifier(message, rs, pubKey);
}
The callVerifier
function encodes the input into the correct format and calls the P256Verify
precompile.
If the returned output is empty, the precompile validation evaluated to false.
This means that the public key derived from the WebAuthn signature does not match the registered r1Owner
public key.
The output data should be 1 (in 32 bytes format) if the signature verification process succeeds, or nothing if it fails.
function callVerifier(bytes32 hash, bytes32[2] memory rs, bytes32[2] memory pubKey) internal view returns (bool) {
/**
* Prepare the input format
* input[ 0: 32] = signed data hash
* input[ 32: 64] = signature r
* input[ 64: 96] = signature s
* input[ 96:128] = public key x
* input[128:160] = public key y
*/
bytes memory input = abi.encodePacked(hash, rs[0], rs[1], pubKey[0], pubKey[1]);
(bool __, bytes memory output) = P256.staticcall(input);
// the precompiled contract does not return a false value
if (output.length == 0) {
return false;
}
return abi.decode(output, (bool));
}
Deploying
Using a Local Node
Open a new terminal and start a local in-memory node with era_test_node
:
era_test_node run
Next, replace your hardhat.config.ts
file with the file below:
Adding the Deploy Script
Create a new file inside the deploy
folder called deploy.ts
:
touch deploy/deploy.ts
Copy and paste the code below.
This script will:
- deploy the
AAFactory
contract - deploy the NFT contract and mint an NFT
- deploy the paymaster contract and send funds to it from the pre-configured rich account.
Running the Deploy Script
Finally, compile and deploy the contracts with:
npm run compile
npm run deploy
Save the output of this command, as we will use these deployed contract addresses in the frontend.
That's all for the contracts! In the next section, we will build the frontend.