diff --git a/modules/passkey/contracts/4337/SafeWebAuthnSharedSigner.sol b/modules/passkey/contracts/4337/SafeWebAuthnSharedSigner.sol index 9329b8e2..f73b6eb0 100644 --- a/modules/passkey/contracts/4337/SafeWebAuthnSharedSigner.sol +++ b/modules/passkey/contracts/4337/SafeWebAuthnSharedSigner.sol @@ -50,11 +50,12 @@ contract SafeWebAuthnSharedSigner is SignatureValidator { * is done as a `DELEGATECALL`, the contract emitting the event is the configured account. This * is also why the event name is prefixed with `SafeWebAuthnSharedSigner`, in order to avoid * event `topic0` collisions with other contracts (seeing as "configured" is a common term). + * @param publicKeyHash The Keccak-256 hash of the public key coordinates. * @param x The x-coordinate of the public key. * @param y The y-coordinate of the public key. * @param verifiers The P-256 verifiers to use. */ - event SafeWebAuthnSharedSignerConfigured(uint256 x, uint256 y, P256.Verifiers verifiers); + event SafeWebAuthnSharedSignerConfigured(bytes32 indexed publicKeyHash, uint256 x, uint256 y, P256.Verifiers verifiers); /** * @notice An error indicating a `CALL` to a function that should only be `DELEGATECALL`-ed. @@ -146,7 +147,8 @@ contract SafeWebAuthnSharedSigner is SignatureValidator { signerStorage.y = signer.y; signerStorage.verifiers = signer.verifiers; - emit SafeWebAuthnSharedSignerConfigured(signer.x, signer.y, signer.verifiers); + bytes32 publicKeyHash = keccak256(abi.encode(signer.x, signer.y)); + emit SafeWebAuthnSharedSignerConfigured(publicKeyHash, signer.x, signer.y, signer.verifiers); } /** diff --git a/modules/passkey/test/4337/SafeWebAuthnSharedSigner.spec.ts b/modules/passkey/test/4337/SafeWebAuthnSharedSigner.spec.ts index 30f5a3b7..d27a5cf9 100644 --- a/modules/passkey/test/4337/SafeWebAuthnSharedSigner.spec.ts +++ b/modules/passkey/test/4337/SafeWebAuthnSharedSigner.spec.ts @@ -178,6 +178,7 @@ describe('SafeWebAuthnSharedSigner', () => { y: ethers.id('publicKey.y'), verifiers: ethers.toBeHex(await mockVerifier.getAddress(), 32), } + const publicKeyHash = ethers.solidityPackedKeccak256(['uint256', 'uint256'], [config.x, config.y]) const initializer = safeSingleton.interface.encodeFunctionData('setup', [ [sharedSigner.target], @@ -193,7 +194,7 @@ describe('SafeWebAuthnSharedSigner', () => { const account = await proxyFactory.createProxyWithNonce.staticCall(safeSingleton, initializer, 0) await expect(proxyFactory.createProxyWithNonce(safeSingleton, initializer, 0)) .to.emit(sharedSigner.attach(account), 'SafeWebAuthnSharedSignerConfigured') - .withArgs(config.x, config.y, config.verifiers) + .withArgs(publicKeyHash, config.x, config.y, config.verifiers) }) it('Should revert if not DELEGATECALL-ed', async () => { diff --git a/modules/passkey/test/libraries/WebAuthn.spec.ts b/modules/passkey/test/libraries/WebAuthn.spec.ts index bb7e9f9d..ea942b2a 100644 --- a/modules/passkey/test/libraries/WebAuthn.spec.ts +++ b/modules/passkey/test/libraries/WebAuthn.spec.ts @@ -161,7 +161,7 @@ describe('WebAuthn Library', () => { // a large enough client data and exact gas limits to make this happen is a bit annoying, so // lets hope for no gas schedule changes :fingers_crossed:. const longClientDataFields = `"long":"${'a'.repeat(100000)}"` - await expect(webAuthnLib.encodeSigningMessage(ethers.ZeroHash, '0x', longClientDataFields, { gasLimit: 1701001 })).to.be.reverted + await expect(webAuthnLib.encodeSigningMessage(ethers.ZeroHash, '0x', longClientDataFields, { gasLimit: 1699001 })).to.be.reverted }) }) diff --git a/modules/passkey/test/userstories/SafeAddressForPasskey.spec.ts b/modules/passkey/test/userstories/SafeAddressForPasskey.spec.ts new file mode 100644 index 00000000..1272d68e --- /dev/null +++ b/modules/passkey/test/userstories/SafeAddressForPasskey.spec.ts @@ -0,0 +1,172 @@ +import { expect } from 'chai' +import { deployments, ethers } from 'hardhat' + +import { WebAuthnCredentials } from '../../test/utils/webauthnShim' +import { decodePublicKey } from '../../src/utils/webauthn' + +/** + * User story: Find Safe for Passkey + * This user story demonstrates how to compute the address of a Safe deterministically for a given + * WebAuthn credential. Note that searching for Safes by owner is not really practical without a + * service (as building Safe owners from Ethereum logs is non-trivial). Instead we show that, given + * a Dapp-specific initial Safe setup with a passkey owner, it is possible to find the Safe address + * corresponding to the passkey. + */ +describe('Safe Address for Passkey [@userstory]', () => { + const setupTests = deployments.createFixture(async ({ deployments }) => { + const { SafeProxyFactory, SafeL2, FCLP256Verifier, SafeWebAuthnSignerFactory, SafeWebAuthnSharedSigner } = await deployments.run() + + const safeProxyFactory = await ethers.getContractAt(SafeProxyFactory.abi, SafeProxyFactory.address) + const safeSingleton = await ethers.getContractAt(SafeL2.abi, SafeL2.address) + const signerFactory = await ethers.getContractAt('SafeWebAuthnSignerFactory', SafeWebAuthnSignerFactory.address) + const sharedSigner = await ethers.getContractAt('SafeWebAuthnSharedSigner', SafeWebAuthnSharedSigner.address) + const verifier = await ethers.getContractAt('IP256Verifier', FCLP256Verifier.address) + + const navigator = { + credentials: new WebAuthnCredentials(), + } + + 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 signerConfig = { + ...decodePublicKey(credential.response), + verifiers: ethers.solidityPacked(['uint16', 'address'], [0, await verifier.getAddress()]), + } + + const deploySafe = async ({ initializer, saltNonce }: { initializer: string; saltNonce: bigint }) => { + const safeAddress = await safeProxyFactory.createProxyWithNonce.staticCall(safeSingleton, initializer, saltNonce) + await safeProxyFactory.createProxyWithNonce(safeSingleton, initializer, saltNonce) + return await ethers.getContractAt(SafeL2.abi, safeAddress) + } + + return { + safeSingleton, + safeProxyFactory, + signerFactory, + sharedSigner, + signerConfig, + deploySafe, + } + }) + + it('should compute the Safe address owned by a WebAuthn proxy signer', async () => { + const { safeSingleton, safeProxyFactory, signerFactory, signerConfig, deploySafe } = await setupTests() + + await signerFactory.getSigner(signerConfig.x, signerConfig.y, signerConfig.verifiers) + const signer = await ethers.getContractAt( + 'SafeWebAuthnSignerSingleton', + await signerFactory.getSigner(signerConfig.x, signerConfig.y, signerConfig.verifiers), + ) + + const initializer = safeSingleton.interface.encodeFunctionData('setup', [ + [await signer.getAddress()], + 1, + ethers.ZeroAddress, + '0x', + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]) + const saltNonce = 42n + + const safe = await deploySafe({ initializer, saltNonce }) + const deterministicSafeAddress = ethers.getCreate2Address( + await safeProxyFactory.getAddress(), + ethers.solidityPackedKeccak256(['bytes32', 'uint256'], [ethers.keccak256(initializer), saltNonce]), + ethers.solidityPackedKeccak256( + ['bytes', 'bytes'], + [ + await safeProxyFactory.proxyCreationCode(), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [await safeSingleton.getAddress()]), + ], + ), + ) + + expect(deterministicSafeAddress).to.equal(await safe.getAddress()) + }) + + it('should compute the Safe address owned by a WebAuthn shared signer', async () => { + const { safeSingleton, safeProxyFactory, sharedSigner, signerConfig, deploySafe } = await setupTests() + + const initializer = safeSingleton.interface.encodeFunctionData('setup', [ + [await sharedSigner.getAddress()], + 1, + await sharedSigner.getAddress(), + sharedSigner.interface.encodeFunctionData('configure', [signerConfig]), + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]) + const saltNonce = 42n + + const safe = await deploySafe({ initializer, saltNonce }) + const deterministicSafeAddress = ethers.getCreate2Address( + await safeProxyFactory.getAddress(), + ethers.solidityPackedKeccak256(['bytes32', 'uint256'], [ethers.keccak256(initializer), saltNonce]), + ethers.solidityPackedKeccak256( + ['bytes', 'bytes'], + [ + await safeProxyFactory.proxyCreationCode(), + ethers.AbiCoder.defaultAbiCoder().encode(['address'], [await safeSingleton.getAddress()]), + ], + ), + ) + + expect(deterministicSafeAddress).to.equal(await safe.getAddress()) + }) + + it('should search for Safes owned by a WebAuthn shared signer', async () => { + const { safeSingleton, sharedSigner, signerConfig, deploySafe } = await setupTests() + + const safe = await deploySafe({ + initializer: safeSingleton.interface.encodeFunctionData('setup', [ + [await sharedSigner.getAddress()], + 1, + await sharedSigner.getAddress(), + sharedSigner.interface.encodeFunctionData('configure', [signerConfig]), + ethers.ZeroAddress, + ethers.ZeroAddress, + 0, + ethers.ZeroAddress, + ]), + saltNonce: 0n, + }) + + let foundSafeAddress = null + + const publicKeyHash = ethers.solidityPackedKeccak256(['uint256', 'uint256'], [signerConfig.x, signerConfig.y]) + const configuredSafes = await ethers.provider.getLogs({ + topics: sharedSigner.interface.encodeFilterTopics('SafeWebAuthnSharedSignerConfigured', [publicKeyHash]), + fromBlock: 0, + }) + for (const { address: possibleSafeAddress } of configuredSafes) { + const possibleSafe = safeSingleton.attach(possibleSafeAddress) as typeof safeSingleton + + const { x, y } = await sharedSigner.getConfiguration(possibleSafe) + const isOwner = await possibleSafe.isOwner(sharedSigner) + + if (signerConfig.x === x && signerConfig.y === y && isOwner) { + foundSafeAddress = possibleSafe + break + } + } + + expect(foundSafeAddress).to.equal(await safe.getAddress()) + }) +})