Skip to content

Commit

Permalink
Use Account Storage for Shared WebAuthn Signer (#412)
Browse files Browse the repository at this point in the history
Fixes #408

This PR reworks the WebAuthn singleton signer to use the account storage
instead of its own storage for signer configuration (x, y, and verifiers
configuration values). This allows the shared signer to work **without**
a staked factory 🎉!

We also rename the methods a bit to be more consistent with our signer
factory implementation, and adjust the E2E test accordingly (noting that
a staked factory is no longer needed).

This also required some small adjustments to the
`4337-passkeys-singleton-signer` example for the new code (but overall
simplification - no need for a staked factory anymore).
  • Loading branch information
nlordell committed May 13, 2024
1 parent b143b6f commit 1508ce5
Show file tree
Hide file tree
Showing 11 changed files with 148 additions and 78 deletions.
2 changes: 1 addition & 1 deletion examples/4337-passkeys-singleton-signer/README.md
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
# Safe + 4337 + Passkeys example application

This minimalistic example application demonstrates a Safe{Core} Smart Account deployment leveraging 4337 and Passkeys. It uses experimental and unaudited (at the moment of writing) contracts: [TestWebAuthnSingletonSigner](https://github.com/safe-global/safe-modules/blob/64fda14111b800ec23b48d93a1288324725fd579/modules/passkey/contracts/test/TestWebAuthnSingletonSigner.sol) and [TestStakedFactory](https://github.com/safe-global/safe-modules/blob/64fda14111b800ec23b48d93a1288324725fd579/modules/4337/contracts/test/TestStakedFactory.sol). The `TestWebAuthnSingletonSigner` allows specifying any signature verifier, including the precompile, but the app chooses to use [FreshCryptoLib](https://github.com/rdubois-crypto/FreshCryptoLib/) verifier under the hood.
This minimalistic example application demonstrates a Safe{Core} Smart Account deployment leveraging 4337 and Passkeys. It uses experimental and unaudited (at the moment of writing) contracts: [TestWebAuthnSingletonSigner](https://github.com/safe-global/safe-modules/blob/64fda14111b800ec23b48d93a1288324725fd579/modules/passkey/contracts/test/TestWebAuthnSingletonSigner.sol). The `TestWebAuthnSingletonSigner` allows specifying any signature verifier, including the precompile, but the app chooses to use [FreshCryptoLib](https://github.com/rdubois-crypto/FreshCryptoLib/) verifier under the hood.

## Running the app

Expand Down
7 changes: 2 additions & 5 deletions examples/4337-passkeys-singleton-signer/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,14 +10,12 @@ const APP_CHAIN_SHORTNAME = 'sep'
the production deployment packages, thus we need to hardcode their addresses here.
Deployment commit: https://github.com/safe-global/safe-modules/commit/3853f34f31837e0a0aee47a4452564278f8c62ba
*/
const SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS = '0xc96cf9547422ea3064A25D38Da70491647494254'
const SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS = '0x608Cf2e3412c6BDA14E6D8A0a7D27c4240FeD6F1'

const SAFE_MULTISEND_ADDRESS = '0x38869bf66a61cF6bDB996A6aE40D5853Fd43B526'

const SAFE_4337_MODULE_ADDRESS = '0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226'

const SAFE_4337_STAKED_FACTORY = '0x734a44bF3100eAe3cf97253f3109d0b7B8F6620E'

const SAFE_MODULE_SETUP_ADDRESS = '0x2dd68b007B46fBe91B9A7c3EDa5A7a1063cB5b47'

const P256_VERIFIER_ADDRESS = '0xcA89CBa4813D5B40AeC6E57A30d0Eeb500d6531b' // FCLP256Verifier
Expand All @@ -35,8 +33,7 @@ export {
APP_CHAIN_ID,
ENTRYPOINT_ADDRESS,
SAFE_MULTISEND_ADDRESS,
SAFE_4337_STAKED_FACTORY,
SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
SAFE_4337_MODULE_ADDRESS,
SAFE_PROXY_FACTORY_ADDRESS,
SAFE_SINGLETON_ADDRESS,
Expand Down
32 changes: 16 additions & 16 deletions examples/4337-passkeys-singleton-signer/src/logic/safe.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,52 @@ import { ethers } from 'ethers'
import { abi as SetupModuleSetupAbi } from '@safe-global/safe-4337/build/artifacts/contracts/SafeModuleSetup.sol/SafeModuleSetup.json'
import { abi as SafeSingletonAbi } from '@safe-global/safe-contracts/build/artifacts/contracts/Safe.sol/Safe.json'
import { abi as MultiSendAbi } from '@safe-global/safe-contracts/build/artifacts/contracts/libraries/MultiSend.sol/MultiSend.json'
import { abi as SafeWebAuthnSingletonSignerAbi } from '@safe-global/safe-passkey/build/artifacts/contracts/test/TestWebAuthnSingletonSigner.sol/TestWebAuthnSingletonSigner.json'
import { abi as SafeWebAuthnSharedSignerAbi } from '@safe-global/safe-passkey/build/artifacts/contracts/4337/SafeWebAuthnSharedSigner.sol/SafeWebAuthnSharedSigner.json'
import { abi as Safe4337ModuleAbi } from '@safe-global/safe-4337/build/artifacts/contracts/Safe4337Module.sol/Safe4337Module.json'
import { abi as SafeProxyFactoryAbi } from '@safe-global/safe-4337/build/artifacts/@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol/SafeProxyFactory.json'
import type { Safe4337Module, SafeModuleSetup, SafeProxyFactory, SafeL2, MultiSend } from '@safe-global/safe-4337/dist/typechain-types/'
import type { TestWebAuthnSingletonSigner } from '@safe-global/safe-passkey/dist/typechain-types/'
import type { SafeWebAuthnSharedSigner } from '@safe-global/safe-passkey/dist/typechain-types/'

import {
SAFE_MODULE_SETUP_ADDRESS,
SAFE_PROXY_FACTORY_ADDRESS,
SAFE_SINGLETON_ADDRESS,
SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
} from '../config'
import { PackedUserOperation } from './userOp'

// Hardcoded because we cannot easily install @safe-global/safe-contracts because of conflicting ethers.js versions
const SafeProxyBytecode =
'0x608060405234801561001057600080fd5b506040516101e63803806101e68339818101604052602081101561003357600080fd5b8101908080519060200190929190505050600073ffffffffffffffffffffffffffffffffffffffff168173ffffffffffffffffffffffffffffffffffffffff1614156100ca576040517f08c379a00000000000000000000000000000000000000000000000000000000081526004018080602001828103825260228152602001806101c46022913960400191505060405180910390fd5b806000806101000a81548173ffffffffffffffffffffffffffffffffffffffff021916908373ffffffffffffffffffffffffffffffffffffffff1602179055505060ab806101196000396000f3fe608060405273ffffffffffffffffffffffffffffffffffffffff600054167fa619486e0000000000000000000000000000000000000000000000000000000060003514156050578060005260206000f35b3660008037600080366000845af43d6000803e60008114156070573d6000fd5b3d6000f3fea264697066735822122003d1488ee65e08fa41e58e888a9865554c535f2c77126a82cb4c0f917f31441364736f6c63430007060033496e76616c69642073696e676c65746f6e20616464726573732070726f7669646564'

type WebAuthnSingletonSignerData = {
type WebAuthnSharedSignerData = {
x: string
y: string
verifiers: string
}

/**
* Encodes the function data for setting the owner of the WebAuthnSingletonSigner.
* Encodes the function data for setting the owner of the WebAuthnSharedSigner.
*
* @param signer The WebAuthnSingletonSignerData object containing the signer data.
* @param signer The WebAuthnSharedSignerData object containing the signer data.
* @returns The encoded function data for setting the owner.
*/
function encodeWebAuthnSingletonSetOwner(signer: WebAuthnSingletonSignerData): string {
const safeWebAuthnSignerSingletonInterface = new ethers.Interface(
SafeWebAuthnSingletonSignerAbi,
) as unknown as TestWebAuthnSingletonSigner['interface']
function encodeWebAuthnSharedSignerConfigure(signer: WebAuthnSharedSignerData): string {
const safeWebAuthnSharedSignerInterface = new ethers.Interface(
SafeWebAuthnSharedSignerAbi,
) as unknown as SafeWebAuthnSharedSigner['interface']

return safeWebAuthnSignerSingletonInterface.encodeFunctionData('setOwner', [{ ...signer }])
return safeWebAuthnSharedSignerInterface.encodeFunctionData('configure', [{ ...signer }])
}

/**
* Encodes the function call for setting up the Safe contract with the specified modules and signer.
*
* @param modules The addresses of the modules to enable.
* @param signer The WebAuthnSingletonSignerData object containing the signer data.
* @param signer The WebAuthnSharedSignerData object containing the signer data.
* @returns The encoded function call data.
*/
function encodeSetupCall(modules: string[], signer: WebAuthnSingletonSignerData): string {
function encodeSetupCall(modules: string[], signer: WebAuthnSharedSignerData): string {
const multiSend = new ethers.Interface(MultiSendAbi) as unknown as MultiSend['interface']

return multiSend.encodeFunctionData('multiSend', [
Expand All @@ -58,9 +58,9 @@ function encodeSetupCall(modules: string[], signer: WebAuthnSingletonSignerData)
data: encodeSafeModuleSetupCall(modules),
},
{
op: 0 as const,
to: SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
data: encodeWebAuthnSingletonSetOwner(signer),
op: 1 as const,
to: SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
data: encodeWebAuthnSharedSignerConfigure(signer),
},
]),
])
Expand Down
6 changes: 3 additions & 3 deletions examples/4337-passkeys-singleton-signer/src/logic/userOp.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { ethers } from 'ethers'
import { abi as EntryPointAbi } from '@account-abstraction/contracts/artifacts/EntryPoint.json'
import { IEntryPoint } from '@safe-global/safe-4337/dist/typechain-types'
import { getExecuteUserOpData, getValidateUserOpData } from './safe'
import { APP_CHAIN_ID, ENTRYPOINT_ADDRESS, SAFE_4337_MODULE_ADDRESS, SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS } from '../config'
import { APP_CHAIN_ID, ENTRYPOINT_ADDRESS, SAFE_4337_MODULE_ADDRESS, SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS } from '../config'
import { PasskeyLocalStorageFormat, signWithPasskey } from './passkeys'
import { calculateSafeOperationHash, unpackGasParameters, SafeUserOperation } from '@safe-global/safe-4337/dist/src/utils/userOp.js'
import {
Expand Down Expand Up @@ -97,7 +97,7 @@ function dummySignatureUserOp() {
0,
buildSignatureBytes([
{
signer: SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
signer: SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
data: getSignatureBytes({
authenticatorData: DUMMY_AUTHENTICATOR_DATA,
clientDataFields: DUMMY_CLIENT_DATA_FIELDS,
Expand Down Expand Up @@ -341,7 +341,7 @@ async function signAndSendUserOp(
const passkeySignature = await signWithPasskey(passkey.rawId, safeOpHash)
const signatureBytes = buildSignatureBytes([
{
signer: SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
signer: SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
data: passkeySignature,
dynamic: true,
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,11 @@ import {
import {
SAFE_4337_MODULE_ADDRESS,
SAFE_SINGLETON_ADDRESS,
SAFE_PROXY_FACTORY_ADDRESS,
P256_VERIFIER_ADDRESS,
SAFE_MULTISEND_ADDRESS,
SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS,
SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS,
XANDER_BLAZE_NFT_ADDRESS,
SAFE_4337_STAKED_FACTORY,
} from '../config'
import { getPasskeyFromLocalStorage, PasskeyLocalStorageFormat } from '../logic/passkeys'
import {
Expand Down Expand Up @@ -61,18 +61,18 @@ function DeploySafe() {
[passkey],
)
const initializer = useMemo(() => {
const owners = [SAFE_SINGLETON_WEBAUTHN_SIGNER_ADDRESS]
const owners = [SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS]
if (address) owners.push(address)

return getSafeInitializer(owners, 1, SAFE_4337_MODULE_ADDRESS, SAFE_MULTISEND_ADDRESS, setupData)
}, [setupData, address])
const safeAddress = useMemo(() => safeAddressFromStorage || getSafeAddress(initializer), [initializer, safeAddressFromStorage])

const { walletProvider } = useOutletContext()
const [safeCode, safeCodeStatus] = useCodeAtAddress(walletProvider, safeAddress)
const callData = useMemo(() => encodeSafeMintData(safeAddress), [safeAddress])
const initCode = useMemo(
() => getUserOpInitCode(SAFE_4337_STAKED_FACTORY, getSafeDeploymentData(SAFE_SINGLETON_ADDRESS, initializer)),
() => getUserOpInitCode(SAFE_PROXY_FACTORY_ADDRESS, getSafeDeploymentData(SAFE_SINGLETON_ADDRESS, initializer)),
[initializer],
)

Expand Down
2 changes: 1 addition & 1 deletion examples/4337-passkeys/src/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,7 @@ const APP_CHAIN_SHORTNAME = 'sep'
the production deployment packages, thus we need to hardcode their addresses here.
Deployment commit: https://github.com/safe-global/safe-modules/commit/3853f34f31837e0a0aee47a4452564278f8c62ba
*/
const SAFE_SIGNER_LAUNCHPAD_ADDRESS = '0xEC909139De44e3d1403602B06cc0BB1ab3C143f2'
const SAFE_SIGNER_LAUNCHPAD_ADDRESS = '0x2804BAA6635d97281FB4d1F011B4BF55DD7A5325'

const SAFE_4337_MODULE_ADDRESS = '0x75cf11467937ce3F2f357CE24ffc3DBF8fD5c226'

Expand Down
2 changes: 1 addition & 1 deletion modules/passkey/contracts/4337/SafeSignerLaunchpad.sol
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ contract SafeSignerLaunchpad is IAccount, SafeStorage {
error InvalidEntryPoint();

/**
* @notice An error a call to a function that should only be `DELEGATECALL`-ed from an account proxy.
* @notice An error indicating a `CALL` to a function that should only be `DELEGATECALL`-ed from an account proxy.
*/
error NotProxied();

Expand Down
8 changes: 8 additions & 0 deletions modules/passkey/contracts/interfaces/ISafe.sol
Original file line number Diff line number Diff line change
Expand Up @@ -31,4 +31,12 @@ interface ISafe {
uint256 payment,
address payable paymentReceiver
) external;

/**
* @notice Reads `length` bytes of storage in the currents contract
* @param offset - the offset in the current contract's storage in words to start reading from
* @param length - the number of words (32 bytes) of data to read
* @return the bytes that were read.
*/
function getStorageAt(uint256 offset, uint256 length) external view returns (bytes memory);
}
1 change: 0 additions & 1 deletion modules/passkey/contracts/test/TestDependencies.sol
Original file line number Diff line number Diff line change
Expand Up @@ -5,5 +5,4 @@ pragma solidity >=0.8.0;
import "@account-abstraction/contracts/interfaces/IEntryPoint.sol";
import "@account-abstraction/contracts/samples/VerifyingPaymaster.sol";
import "@safe-global/mock-contract/contracts/MockContract.sol";
import "@safe-global/safe-4337/contracts/test/TestStakedFactory.sol";
import "@safe-global/safe-contracts/contracts/proxies/SafeProxyFactory.sol";
120 changes: 104 additions & 16 deletions modules/passkey/contracts/test/TestWebAuthnSingletonSigner.sol
Original file line number Diff line number Diff line change
Expand Up @@ -2,52 +2,140 @@
pragma solidity >=0.8.0;

import {SignatureValidator} from "../base/SignatureValidator.sol";
import {ISafe} from "../interfaces/ISafe.sol";
import {P256, WebAuthn} from "../libraries/WebAuthn.sol";

/**
* @title WebAuthn Singleton Signer
* @dev A contract for verifying WebAuthn signatures for multiple accounts.
* @dev A contract for verifying WebAuthn signatures shared by all Safe accounts. This contract uses
* storage from the Safe account itself for full ERC-4337 compatibility.
*/
contract TestWebAuthnSingletonSigner is SignatureValidator {
/**
* @notice Data associated with a WebAuthn signer. It represents the X and Y coordinates of the
* signer's public key. This is stored in a mapping using the account address as the key.
*/
struct OwnerData {
struct Signer {
uint256 x;
uint256 y;
P256.Verifiers verifiers;
}

/**
* @notice A mapping of account address to public keys of the owner.
* @notice The starting storage slot containing the signer data.
* @custom:computed-as keccak256("SafeWebAuthnSharedSigner.signer") - 3
* @dev This value is intentionally computed to be a hash -3 as a precaution to avoid any
* potential issues from unintended hash collisions, and have enough space for all the signer
* fields.
*/
mapping(address => OwnerData) private owners;
uint256 private constant _SIGNER_SLOT = 0x2e0aed53485dc2290ceb5ce14725558ad3e3a09d38c69042410ad15c2b4ea4e6;

/**
* @notice Return the owner data for the specified account.
* @param account The account to request owner data for.
* @notice An error indicating a `CALL` to a function that should only be `DELEGATECALL`-ed.
*/
function getOwner(address account) external view returns (OwnerData memory owner) {
owner = owners[account];
error NotDelegateCalled();

/**
* @dev Address of the launchpad contract itself. it is used for determining whether or not the
* contract is being `DELEGATECALL`-ed when setting signer data.
*/
address private immutable _SELF;

/**
* @notice Create a new shared WebAuthn signer instance.
*/
constructor() {
_SELF = address(this);
}

/**
* @notice Sets the owner data for the calling account.
* @param owner The new owner data to set for the calling account.
* @notice Validates the call is done via `DELEGATECALL`.
*/
function setOwner(OwnerData memory owner) external {
owners[msg.sender] = owner;
modifier onlyDelegateCall() {
if (address(this) == _SELF) {
revert NotDelegateCalled();
}
_;
}

/**
* @notice Return the signer configuration for the specified account.
* @dev The calling account must be a Safe, as the signer data is stored in the Safe's storage
* and must be read with the {StorageAccessible} support from the Safe.
* @param account The account to request signer data for.
*/
function getConfiguration(address account) public view returns (Signer memory signer) {
bytes memory getStorageAtData = abi.encodeCall(ISafe(account).getStorageAt, (_SIGNER_SLOT, 3));

// Call the {StorageAccessible.getStorageAt} with assembly. This allows us to return a
// zeroed out signer configuration instead of reverting for `account`s that are not Safes.
// We also, expect the implementation to behave **exactly** like the Safe's - that is it
// should encode the return data using a standard ABI encoding:
// - The first 32 bytes is the offset of the values bytes array, always `0x20`
// - The second 32 bytes is the length of the values bytes array, always `0x60`
// - the following 3 words (96 bytes) are the values of the signer configuration.

// solhint-disable-next-line no-inline-assembly
assembly ("memory-safe") {
// Note that Yul expressions are evaluated in reverse order, so the `staticcall` is the
// first thing to be evaluated in the nested `and` expression.
if and(
and(
// The offset of the ABI encoded bytes is 0x20, this should always be the case
// for standard ABI encoding of `(bytes)` tuple that `getStorageAt` returns.
eq(mload(0x00), 0x20),
// The length of the encoded bytes is exactly 0x60 bytes (i.e. 3 words, which is
// exactly how much we read from the Safe's storage in the `getStorageAt` call).
eq(mload(0x20), 0x60)
),
and(
// The length of the return data should be exactly 0xa0 bytes, which should
// always be the case for the Safe's `getStorageAt` implementation.
eq(returndatasize(), 0xa0),
// The call succeeded. We write the first two words of the return data into the
// scratch space, as we need to inspect them before copying the signer
// signer configuration to our `signer` memory pointer.
staticcall(gas(), account, add(getStorageAtData, 0x20), mload(getStorageAtData), 0x00, 0x40)
)
) {
// Copy only the storage values from the return data to our `signer` memory address.
// This only happens on success, so the `signer` value will be zeroed out if any of
// the above conditions fail, indicating that no signer is configured.
returndatacopy(signer, 0x40, 0x60)
}
}
}

/**
* @notice Sets the signer configuration for the calling account.
* @dev The Safe must call this function with a `DELEGATECALL`, as the signer data is stored in
* the Safe account's storage.
* @param signer The new signer data to set for the calling account.
*/
function configure(Signer memory signer) external onlyDelegateCall {
Signer storage signerSlot;

// solhint-disable-next-line no-inline-assembly
assembly ("memory-safe") {
signerSlot.slot := _SIGNER_SLOT
}

signerSlot.x = signer.x;
signerSlot.y = signer.y;
signerSlot.verifiers = signer.verifiers;
}

/**
* @inheritdoc SignatureValidator
*/
function _verifySignature(bytes32 message, bytes calldata signature) internal view virtual override returns (bool isValid) {
OwnerData memory owner = owners[msg.sender];
Signer memory signer = getConfiguration(msg.sender);

// Make sure that the signer is configured in the first place.
if (P256.Verifiers.unwrap(signer.verifiers) == 0) {
return false;
}

isValid =
P256.Verifiers.unwrap(owner.verifiers) != 0 &&
WebAuthn.verifySignature(message, signature, WebAuthn.USER_VERIFICATION, owner.x, owner.y, owner.verifiers);
isValid = WebAuthn.verifySignature(message, signature, WebAuthn.USER_VERIFICATION, signer.x, signer.y, signer.verifiers);
}
}
Loading

0 comments on commit 1508ce5

Please sign in to comment.