From 426d756c6b6cf88db44191d9dcca99be16ca44bc Mon Sep 17 00:00:00 2001 From: Gabriel Speckhahn <749488+gabspeck@users.noreply.github.com> Date: Mon, 25 Nov 2024 10:28:09 +0000 Subject: [PATCH] feat: ERC-1271 support and EIP-712 signature demo (#14) * feat: EIP-1271 signature verification * feat: signature validation and EIP-712 signing demo * add NatSpec docs --- packages/contracts/contracts/Account.sol | 29 ++++- packages/contracts/package.json | 2 +- packages/contracts/test/Account.ts | 16 +++ services/web/src/client/components/Login.tsx | 2 + services/web/src/client/components/Wallet.tsx | 102 +++++++++++++++++- 5 files changed, 147 insertions(+), 4 deletions(-) diff --git a/packages/contracts/contracts/Account.sol b/packages/contracts/contracts/Account.sol index ae9febc..4b6df09 100644 --- a/packages/contracts/contracts/Account.sol +++ b/packages/contracts/contracts/Account.sol @@ -3,13 +3,16 @@ pragma solidity ^0.8.23; import {WebAuthn} from './WebAuthn.sol'; import {ReentrancyGuard} from '@openzeppelin/contracts/utils/ReentrancyGuard.sol'; +import {IERC1271} from '@openzeppelin/contracts/interfaces/IERC1271.sol'; import {Types} from './Types.sol'; - /** A smart wallet implementation that allows you to execute arbitrary functions in contracts */ -contract Account is ReentrancyGuard { +contract Account is ReentrancyGuard, IERC1271 { + // bytes4(keccak256("isValidSignature(bytes32,bytes)") + bytes4 internal constant ERC1271_MAGICVALUE = 0x1626ba7e; + error InvalidSignature(); Types.PublicKey private publicKey; @@ -19,10 +22,17 @@ contract Account is ReentrancyGuard { publicKey = _publicKey; } + /** + * Returns the expected challenge for a given call payload + * @param call The call parameters to generate the challenge against + */ function getChallenge(Types.Call calldata call) public view returns (bytes32) { return keccak256(bytes.concat(bytes20(address(this)), bytes32(currentNonce), bytes20(call.target), bytes32(call.value), call.data)); } + /** + * Returns the x and y coordinates of the public key associated with this contract + */ function getPublicKey() public view returns (Types.PublicKey memory) { return publicKey; } @@ -40,6 +50,11 @@ contract Account is ReentrancyGuard { // solhint-disable-next-line no-empty-blocks fallback() external payable {} + /** + * Execute an arbitrary call on a smart contract, optionally sending a value in ETH + * @param signed The parameters of the call to be executed + * @notice The call parameters must be signed with the key associated with this contract + */ function execute(Types.SignedCall calldata signed) external payable validSignature(bytes.concat(getChallenge(signed.call)), signed.signature) nonReentrant { (bool success, bytes memory result) = signed.call.target.call{value: signed.call.value}(signed.call.data); if (!success) { @@ -67,4 +82,14 @@ contract Account is ReentrancyGuard { y: uint256(publicKey.y) }); } + + /** + * @inheritdoc IERC1271 + */ + function isValidSignature(bytes32 messageHash, bytes calldata signature) public view override returns (bytes4 magicValue) { + if (_validateSignature(bytes.concat(messageHash), signature)) { + return ERC1271_MAGICVALUE; + } + return 0xffffffff; + } } diff --git a/packages/contracts/package.json b/packages/contracts/package.json index 5e211d6..18cad97 100644 --- a/packages/contracts/package.json +++ b/packages/contracts/package.json @@ -1,6 +1,6 @@ { "name": "@appliedblockchain/giano-contracts", - "version": "1.0.1", + "version": "1.0.2", "license": "MIT", "type": "commonjs", "scripts": { diff --git a/packages/contracts/test/Account.ts b/packages/contracts/test/Account.ts index e9824f3..8de12a2 100644 --- a/packages/contracts/test/Account.ts +++ b/packages/contracts/test/Account.ts @@ -168,4 +168,20 @@ describe('Account Contract', () => { ).to.be.revertedWithCustomError(account, 'InvalidSignature'); }); }); + describe('ERC-1271 compliance', () => { + it('should return the magic value when checking a valid signature', async () => { + const hash = new Uint8Array(32); + crypto.getRandomValues(hash); + const signature = encodeChallenge(signWebAuthnChallenge(keypair.keyPair.privateKey, hash)); + const result = await account.isValidSignature(hash, signature); + expect(result).to.equal('0x1626ba7e'); + }); + it('should return a constant value when checking an invalid signature', async () => { + const hash = new Uint8Array(32); + crypto.getRandomValues(hash); + const signature = encodeChallenge(signWebAuthnChallenge(keypair.keyPair.privateKey, hexToUint8Array('0xDEADBEEF'))); + const result = await account.isValidSignature(hash, signature); + expect(result).to.equal('0xffffffff'); + }); + }); }); diff --git a/services/web/src/client/components/Login.tsx b/services/web/src/client/components/Login.tsx index 51f765c..fba73fd 100644 --- a/services/web/src/client/components/Login.tsx +++ b/services/web/src/client/components/Login.tsx @@ -102,6 +102,8 @@ const Login: React.FC = () => { rawId: new Uint8Array(credential.rawId), credentialId: userId.toString(), }); + } else { + setSnackbarState({ severity: 'error', open: true, message: 'No user found with the selected passkey' }); } } } catch (e) { diff --git a/services/web/src/client/components/Wallet.tsx b/services/web/src/client/components/Wallet.tsx index da6587b..c8356c1 100644 --- a/services/web/src/client/components/Wallet.tsx +++ b/services/web/src/client/components/Wallet.tsx @@ -1,11 +1,13 @@ +import type { FormEvent } from 'react'; import React, { useCallback, useEffect, useMemo, useState } from 'react'; import type { ProxiedContract } from '@appliedblockchain/giano-client'; import { GianoWalletClient } from '@appliedblockchain/giano-client'; import { encodeChallenge, hexToUint8Array } from '@appliedblockchain/giano-common'; -import type { GenericERC20, GenericERC721 } from '@appliedblockchain/giano-contracts'; +import type { Account, GenericERC20, GenericERC721 } from '@appliedblockchain/giano-contracts'; import { GenericERC20__factory, GenericERC721__factory } from '@appliedblockchain/giano-contracts'; import { Logout } from '@mui/icons-material'; import { Box, Button, Card, CircularProgress, Container, FormControl, MenuItem, Select, Tab, Tabs, TextField, Typography } from '@mui/material'; +import type { TypedDataDomain } from 'ethers'; import { ethers } from 'ethers'; import { getCredential } from 'services/web/src/client/common/credentials'; import type { User } from 'services/web/src/client/common/user'; @@ -104,6 +106,21 @@ type SendCoinsFormProps = { onChange: (e: React.SyntheticEvent) => void; }; +type SignFormValues = { + name: string; + age: string; +}; + +type SignFormProps = { + user: User; + account: Account; + provider: ethers.Provider; + values: SignFormValues; + onSuccess: () => void; + onFailure: (message?: string) => void; + onChange: (e: React.SyntheticEvent) => void; +}; + const SendCoinsForm: React.FC = ({ user, onSuccess, onFailure, coinContractProxy, values, onChange }) => { const [sending, setSending] = useState(false); @@ -156,6 +173,70 @@ const SubmitButtonWithProgress: React.FC = ({ run ); }; +const SignForm: React.FC = ({ values: formValues, onChange, provider, account, onSuccess, onFailure, user }: SignFormProps) => { + const getEip712Parameters = async (name: string, age: bigint) => { + const domain: TypedDataDomain = { + name: 'Giano Account', + version: '1.0', + chainId: (await provider.getNetwork()).chainId, + verifyingContract: await account.getAddress(), + }; + const types = { + SignUpData: [ + { name: 'name', type: 'string' }, + { name: 'age', type: 'uint256' }, + ], + }; + const values = { + name, + age, + }; + return { domain, types, values }; + }; + + const validateSignature = async ({ name, age }: { name: string; age: bigint }, signature: string) => { + const ERC1271_MAGIC = '0x1626ba7e'; + const { domain, types, values } = await getEip712Parameters(name, age); + const hash = ethers.TypedDataEncoder.hash(domain, types, values); + const check = await account.isValidSignature(hash, signature); + if (check === ERC1271_MAGIC) { + return true; + } + return false; + }; + + const [signing, setSigning] = useState(false); + const sign = async (e: FormEvent) => { + e.preventDefault(); + setSigning(true); + try { + const { domain, types, values } = await getEip712Parameters(formValues.name, BigInt(formValues.age)); + const hash = ethers.TypedDataEncoder.hash(domain, types, values); + const signature = await getChallengeSigner(user)(hash); + // pretend this is a backend call + const valid = await validateSignature({ name: formValues.name, age: BigInt(formValues.age) }, signature); + if (valid) { + onSuccess(); + } else { + onFailure('Invalid signature!'); + } + } catch (e) { + const err: Error = e as any; + console.error(err); + onFailure(err.message); + } finally { + setSigning(false); + } + }; + return ( +
+ + + + + ); +}; + function getChallengeSigner(user: User) { return async (challengeHex: string) => { const challenge = hexToUint8Array(challengeHex); @@ -175,6 +256,7 @@ const Wallet: React.FC = () => { const [faucetRunning, setFaucetRunning] = useState(false); const [transferFormValues, setTransferFormValues] = useState({ recipient: '', tokenId: '' }); const [sendCoinsFormValues, setSendCoinsFormValues] = useState({ recipient: '', amount: '' }); + const [signFormValues, setSignFormValues] = useState({ name: '', age: '' }); const provider = useMemo(() => new ethers.WebSocketProvider('ws://localhost:8545'), []); const signer = useMemo(() => new ethers.Wallet('0xac0974bec39a17e36ba4a6b4d238ff944bacb478cbed5efcae784d7bf4f2ff80', provider), [provider]); @@ -339,6 +421,7 @@ const Wallet: React.FC = () => { +
@@ -433,6 +516,23 @@ const Wallet: React.FC = () => { } /> + + {walletClient && user && ( + { + setSnackbarState({ open: true, message: 'Signature validated successfully' }); + }} + onFailure={(message?: string) => { + setSnackbarState({ open: true, message: message ?? 'Something went wrong; please check the console' }); + }} + /> + )} +