From 478254137c59e01dd21bdc86479554094c415daf Mon Sep 17 00:00:00 2001 From: Nicholas Rodrigues Lordello Date: Tue, 9 Jan 2024 09:45:18 +0100 Subject: [PATCH] Add E2E Test with WebAuthn Signer (#197) A huge part of the way to #173 This PR adds an E2E test using the reference 4337 EntryPoint and bundler for a Safe deployment with a WebAuthn (i.e. Passkeys) signer. Note that this E2E test is not 100% complete. In particular, we don't get test using the Safe after the launchpad deployment is completed. I want to add that test in a follow-up when I actually add the gas profiling for the WebAuthn signer. --- 4337/.solcover.js | 11 +- 4337/contracts/test/SafeMock.sol | 2 +- 4337/contracts/test/SafeSignerLaunchpad.sol | 6 +- 4337/contracts/test/TestEntryPoint.sol | 4 +- 4337/contracts/test/TestUniqueSigner.sol | 6 +- 4337/contracts/test/WebAuthnSigner.sol | 89 +++++++ 4337/test/e2e/WebAuthnSigner.spec.ts | 275 ++++++++++++++++++++ 7 files changed, 372 insertions(+), 21 deletions(-) create mode 100644 4337/contracts/test/WebAuthnSigner.sol create mode 100644 4337/test/e2e/WebAuthnSigner.spec.ts diff --git a/4337/.solcover.js b/4337/.solcover.js index 694a6182a..3ed48256d 100644 --- a/4337/.solcover.js +++ b/4337/.solcover.js @@ -1,14 +1,5 @@ -const fs = require('fs') -const path = require('path') - -const testDir = path.join(__dirname, 'contracts', 'test') -const testContracts = fs - .readdirSync(testDir) - .filter((file) => file.endsWith('.sol')) - .map((file) => path.join('test', file)) - module.exports = { - skipFiles: testContracts, + skipFiles: ['test'], mocha: { grep: '@skip-on-coverage', // Find everything with this tag invert: true, // Run the grep's inverse set. diff --git a/4337/contracts/test/SafeMock.sol b/4337/contracts/test/SafeMock.sol index 5a6d6f32b..67f53832b 100644 --- a/4337/contracts/test/SafeMock.sol +++ b/4337/contracts/test/SafeMock.sol @@ -25,7 +25,7 @@ contract SafeMock { function _signatureSplit(bytes memory signature) internal pure returns (uint8 v, bytes32 r, bytes32 s) { // solhint-disable-next-line no-inline-assembly - assembly { + assembly ("memory-safe") { r := mload(add(signature, 0x20)) s := mload(add(signature, 0x40)) v := byte(0, mload(add(signature, 0x60))) diff --git a/4337/contracts/test/SafeSignerLaunchpad.sol b/4337/contracts/test/SafeSignerLaunchpad.sol index 36a8f4f71..202f44ae9 100644 --- a/4337/contracts/test/SafeSignerLaunchpad.sol +++ b/4337/contracts/test/SafeSignerLaunchpad.sol @@ -242,12 +242,10 @@ contract SafeSignerLaunchpad is IAccount, SafeStorage { function _isContract(address account) internal view returns (bool) { uint256 size; - /* solhint-disable no-inline-assembly */ - /// @solidity memory-safe-assembly - assembly { + // solhint-disable-next-line no-inline-assembly + assembly ("memory-safe") { size := extcodesize(account) } - /* solhint-enable no-inline-assembly */ return size > 0; } } diff --git a/4337/contracts/test/TestEntryPoint.sol b/4337/contracts/test/TestEntryPoint.sol index e2b0a1c0c..d2ae16d47 100644 --- a/4337/contracts/test/TestEntryPoint.sol +++ b/4337/contracts/test/TestEntryPoint.sol @@ -20,8 +20,8 @@ contract SenderCreator { address factory = address(bytes20(initCode[0:20])); bytes memory initCallData = initCode[20:]; bool success; - /* solhint-disable no-inline-assembly */ - assembly { + // solhint-disable-next-line no-inline-assembly + assembly ("memory-safe") { success := call(gas(), factory, 0, add(initCallData, 0x20), mload(initCallData), 0, 32) sender := mload(0) } diff --git a/4337/contracts/test/TestUniqueSigner.sol b/4337/contracts/test/TestUniqueSigner.sol index 882ca1555..2d0407fc5 100644 --- a/4337/contracts/test/TestUniqueSigner.sol +++ b/4337/contracts/test/TestUniqueSigner.sol @@ -59,12 +59,10 @@ contract TestUniqueSignerFactory is IUniqueSignerFactory { function _hasNoCode(address account) internal view returns (bool) { uint256 size; - /* solhint-disable no-inline-assembly */ - /// @solidity memory-safe-assembly - assembly { + // solhint-disable-next-line no-inline-assembly + assembly ("memory-safe") { size := extcodesize(account) } - /* solhint-enable no-inline-assembly */ return size == 0; } } diff --git a/4337/contracts/test/WebAuthnSigner.sol b/4337/contracts/test/WebAuthnSigner.sol new file mode 100644 index 000000000..74bf4f615 --- /dev/null +++ b/4337/contracts/test/WebAuthnSigner.sol @@ -0,0 +1,89 @@ +// SPDX-License-Identifier: LGPL-3.0-only +/* solhint-disable one-contract-per-file */ +pragma solidity >=0.8.0; + +import {FCL_WebAuthn} from "./FCL/FCL_Webauthn.sol"; +import {IUniqueSignerFactory} from "./SafeSignerLaunchpad.sol"; + +struct SignatureData { + bytes authenticatorData; + bytes clientData; + uint256 challengeOffset; + uint256[2] rs; +} + +function checkSignature(bytes memory data, bytes calldata signature, uint256 x, uint256 y) view returns (bytes4 magicValue) { + SignatureData calldata signaturePointer; + // solhint-disable-next-line no-inline-assembly + assembly ("memory-safe") { + signaturePointer := signature.offset + } + + if ( + FCL_WebAuthn.checkSignature( + signaturePointer.authenticatorData, + 0x01, // require user presence + signaturePointer.clientData, + keccak256(data), + signaturePointer.challengeOffset, + signaturePointer.rs, + x, + y + ) + ) { + magicValue = WebAuthnSigner.isValidSignature.selector; + } +} + +contract WebAuthnSigner { + uint256 public immutable X; + uint256 public immutable Y; + + constructor(uint256 x, uint256 y) { + X = x; + Y = y; + } + + function isValidSignature(bytes memory data, bytes calldata signature) external view returns (bytes4 magicValue) { + return checkSignature(data, signature, X, Y); + } +} + +contract WebAuthnSignerFactory is IUniqueSignerFactory { + function getSigner(bytes calldata data) public view returns (address signer) { + (uint256 x, uint256 y) = abi.decode(data, (uint256, uint256)); + signer = _getSigner(x, y); + } + + function createSigner(bytes calldata data) external returns (address signer) { + (uint256 x, uint256 y) = abi.decode(data, (uint256, uint256)); + signer = _getSigner(x, y); + if (_hasNoCode(signer)) { + WebAuthnSigner created = new WebAuthnSigner{salt: bytes32(0)}(x, y); + require(address(created) == signer); + } + } + + function isValidSignatureForSigner( + bytes memory data, + bytes calldata signature, + bytes calldata signerData + ) external view override returns (bytes4 magicValue) { + (uint256 x, uint256 y) = abi.decode(signerData, (uint256, uint256)); + magicValue = checkSignature(data, signature, x, y); + } + + function _getSigner(uint256 x, uint256 y) internal view returns (address) { + bytes32 codeHash = keccak256(abi.encodePacked(type(WebAuthnSigner).creationCode, x, y)); + return address(uint160(uint256(keccak256(abi.encodePacked(hex"ff", address(this), bytes32(0), codeHash))))); + } + + function _hasNoCode(address account) internal view returns (bool) { + uint256 size; + // solhint-disable-next-line no-inline-assembly + assembly ("memory-safe") { + size := extcodesize(account) + } + return size == 0; + } +} diff --git a/4337/test/e2e/WebAuthnSigner.spec.ts b/4337/test/e2e/WebAuthnSigner.spec.ts new file mode 100644 index 000000000..a6b9b5306 --- /dev/null +++ b/4337/test/e2e/WebAuthnSigner.spec.ts @@ -0,0 +1,275 @@ +import { expect } from 'chai' +import CBOR from 'cbor' +import { deployments, ethers, network } from 'hardhat' +import { bundlerRpc, prepareAccounts, waitForUserOp } from '../utils/e2e' +import { chainId } from '../utils/encoding' +import { AuthenticatorAttestationResponse, WebAuthnCredentials, base64UrlEncode } from '../utils/webauthn' + +describe('E2E - WebAuthn Signers', () => { + before(function () { + if (network.name !== 'localhost') { + this.skip() + } + }) + + const setupTests = deployments.createFixture(async ({ deployments }) => { + const { EntryPoint, Safe4337Module, SafeSignerLaunchpad, SafeProxyFactory, AddModulesLib, SafeL2, MultiSend } = await deployments.run() + const [user] = await prepareAccounts() + const bundler = bundlerRpc() + + const entryPoint = await ethers.getContractAt('IEntryPoint', EntryPoint.address) + const module = await ethers.getContractAt('Safe4337Module', Safe4337Module.address) + const proxyFactory = await ethers.getContractAt('SafeProxyFactory', SafeProxyFactory.address) + const addModulesLib = await ethers.getContractAt('AddModulesLib', AddModulesLib.address) + const signerLaunchpad = await ethers.getContractAt('SafeSignerLaunchpad', SafeSignerLaunchpad.address) + const singleton = await ethers.getContractAt('SafeL2', SafeL2.address) + const multiSend = await ethers.getContractAt('MultiSend', MultiSend.address) + + const WebAuthnSignerFactory = await ethers.getContractFactory('WebAuthnSignerFactory') + const signerFactory = await WebAuthnSignerFactory.deploy() + + const navigator = { + credentials: new WebAuthnCredentials(), + } + + return { + user, + bundler, + proxyFactory, + addModulesLib, + module, + entryPoint, + signerLaunchpad, + singleton, + multiSend, + signerFactory, + navigator, + } + }) + + it('should execute a user op and deploy a WebAuthn signer', async () => { + const { user, bundler, proxyFactory, addModulesLib, module, entryPoint, signerLaunchpad, singleton, signerFactory, navigator } = + await setupTests() + + const credential = navigator.credentials.create({ + publicKey: { + rp: { + name: 'Safe', + id: 'safe.global', + }, + user: { + id: ethers.getBytes(ethers.id('chucknorris')), + name: 'chucknorris', + displayName: 'Chuck Norris', + }, + challenge: ethers.toBeArray(Date.now()), + pubKeyCredParams: [{ type: 'public-key', alg: -7 }], + }, + }) + const publicKey = extractPublicKey(credential.response) + const signerData = ethers.solidityPacked(['uint256', 'uint256'], [publicKey.x, publicKey.y]) + const signerAddress = await signerFactory.getSigner(signerData) + + const safeInit = { + singleton: singleton.target, + signerFactory: signerFactory.target, + signerData, + setupTo: addModulesLib.target, + setupData: addModulesLib.interface.encodeFunctionData('enableModules', [[module.target]]), + fallbackHandler: module.target, + } + const safeInitHash = ethers.TypedDataEncoder.hash( + { verifyingContract: await signerLaunchpad.getAddress(), chainId: await chainId() }, + { + SafeInit: [ + { type: 'address', name: 'singleton' }, + { type: 'address', name: 'signerFactory' }, + { type: 'bytes', name: 'signerData' }, + { type: 'address', name: 'setupTo' }, + { type: 'bytes', name: 'setupData' }, + { type: 'address', name: 'fallbackHandler' }, + ], + }, + safeInit, + ) + + expect( + await signerLaunchpad.getInitHash( + safeInit.singleton, + safeInit.signerFactory, + safeInit.signerData, + safeInit.setupTo, + safeInit.setupData, + safeInit.fallbackHandler, + ), + ).to.equal(safeInitHash) + + const launchpadInitializer = signerLaunchpad.interface.encodeFunctionData('preValidationSetup', [ + safeInitHash, + ethers.ZeroAddress, + '0x', + ]) + const safeSalt = Date.now() + const safe = await proxyFactory.createProxyWithNonce.staticCall(signerLaunchpad.target, launchpadInitializer, safeSalt) + + const userOp = { + sender: safe, + nonce: ethers.toBeHex(await entryPoint.getNonce(safe, 0)), + initCode: ethers.solidityPacked( + ['address', 'bytes'], + [ + proxyFactory.target, + proxyFactory.interface.encodeFunctionData('createProxyWithNonce', [signerLaunchpad.target, launchpadInitializer, safeSalt]), + ], + ), + callData: signerLaunchpad.interface.encodeFunctionData('initializeThenUserOp', [ + safeInit.singleton, + safeInit.signerFactory, + safeInit.signerData, + safeInit.setupTo, + safeInit.setupData, + safeInit.fallbackHandler, + module.interface.encodeFunctionData('executeUserOp', [user.address, ethers.parseEther('0.5'), '0x', 0]), + ]), + callGasLimit: ethers.toBeHex(2000000), + verificationGasLimit: ethers.toBeHex(500000), + preVerificationGas: ethers.toBeHex(60000), + maxFeePerGas: ethers.toBeHex(10000000000), + maxPriorityFeePerGas: ethers.toBeHex(10000000000), + paymasterAndData: '0x', + } + + const safeInitOp = { + userOpHash: await entryPoint.getUserOpHash({ ...userOp, signature: '0x' }), + validAfter: 0, + validUntil: 0, + entryPoint: entryPoint.target, + } + const safeInitOpHash = ethers.TypedDataEncoder.hash( + { verifyingContract: await signerLaunchpad.getAddress(), chainId: await chainId() }, + { + SafeInitOp: [ + { type: 'bytes32', name: 'userOpHash' }, + { type: 'uint48', name: 'validAfter' }, + { type: 'uint48', name: 'validUntil' }, + { type: 'address', name: 'entryPoint' }, + ], + }, + safeInitOp, + ) + + const assertion = navigator.credentials.get({ + publicKey: { + challenge: ethers.getBytes(safeInitOpHash), + rpId: 'safe.global', + allowCredentials: [{ type: 'public-key', id: new Uint8Array(credential.rawId) }], + }, + }) + const signature = ethers.solidityPacked( + ['uint48', 'uint48', 'bytes'], + [ + safeInitOp.validAfter, + safeInitOp.validUntil, + ethers.AbiCoder.defaultAbiCoder().encode( + ['bytes', 'bytes', 'uint256', 'uint256[2]'], + [ + new Uint8Array(assertion.response.authenticatorData), + new Uint8Array(assertion.response.clientDataJSON), + extractChallengeOffset(assertion.response, safeInitOpHash), + extractSignature(assertion.response), + ], + ), + ], + ) + + await user.sendTransaction({ to: safe, value: ethers.parseEther('1') }).then((tx) => tx.wait()) + expect(await ethers.provider.getBalance(safe)).to.equal(ethers.parseEther('1')) + expect(await ethers.provider.getCode(safe)).to.equal('0x') + expect(await ethers.provider.getCode(signerAddress)).to.equal('0x') + + await bundler.sendUserOperation({ ...userOp, signature }, await entryPoint.getAddress()) + + await waitForUserOp(userOp) + expect(await ethers.provider.getBalance(safe)).to.be.lessThanOrEqual(ethers.parseEther('0.5')) + expect(await ethers.provider.getCode(safe)).to.not.equal('0x') + expect(await ethers.provider.getCode(signerAddress)).to.not.equal('0x') + + const [implementation] = ethers.AbiCoder.defaultAbiCoder().decode(['address'], await ethers.provider.getStorage(safe, 0)) + expect(implementation).to.equal(singleton.target) + + const safeInstance = await ethers.getContractAt('SafeL2', safe) + expect(await safeInstance.getOwners()).to.deep.equal([signerAddress]) + }) + + /** + * Extract the x and y coordinates of the public key from a created public key credential. + * Inspired from . + */ + function extractPublicKey(response: AuthenticatorAttestationResponse): { x: bigint; y: bigint } { + const attestationObject = CBOR.decode(response.attestationObject) + const authDataView = new DataView(attestationObject.authData.buffer) + const credentialIdLength = authDataView.getUint16(53) + const cosePublicKey = attestationObject.authData.slice(55 + credentialIdLength) + const key: Map = CBOR.decode(cosePublicKey) + const bn = (bytes: Uint8Array) => BigInt(ethers.hexlify(bytes)) + return { + x: bn(key.get(-2) as Uint8Array), + y: bn(key.get(-3) as Uint8Array), + } + } + + /** + * Compute the challenge offset in the client data JSON. This is the offset, in bytes, of the + * value associated with the `challenge` key in the JSON blob. + */ + function extractChallengeOffset(response: AuthenticatorAssertionResponse, challenge: string): number { + const clientDataJSON = new TextDecoder('utf-8').decode(response.clientDataJSON) + + const encodedChallenge = base64UrlEncode(challenge) + const offset = clientDataJSON.indexOf(encodedChallenge) + if (offset < 0) { + throw new Error('challenge not found in client data JSON') + } + + return offset + } + + /** + * Extracts the signature into R and S values from the authenticator response. + * + * See: + * - + * - + */ + function extractSignature(response: AuthenticatorAssertionResponse): [bigint, bigint] { + const check = (x: boolean) => { + if (!x) { + throw new Error('invalid signature encoding') + } + } + + // Decode the DER signature. Note that we assume that all lengths fit into 8-bit integers, + // which is true for the kinds of signatures we are decoding but generally false. I.e. this + // code should not be used in any serious application. + const view = new DataView(response.signature) + + // check that the sequence header is valid + check(view.getUint8(0) === 0x30) + check(view.getUint8(1) === view.byteLength - 2) + + // read r and s + const readInt = (offset: number) => { + check(view.getUint8(offset) === 0x02) + const len = view.getUint8(offset + 1) + const start = offset + 2 + const end = start + len + const n = BigInt(ethers.hexlify(new Uint8Array(view.buffer.slice(start, end)))) + check(n < ethers.MaxUint256) + return [n, end] as const + } + const [r, sOffset] = readInt(2) + const [s] = readInt(sOffset) + + return [r, s] + } +})