Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Passkey support #808

Merged
merged 49 commits into from
Aug 23, 2024
Merged

Passkey support #808

merged 49 commits into from
Aug 23, 2024

Conversation

DaniSomoza
Copy link
Contributor

@DaniSomoza DaniSomoza commented May 8, 2024

What it solves

Resolves #789 & #793 by adding support for passkeys in the protocol-kit.

How this PR fixes it

protocol-kit instantiation using passkeys

You can create an instance of the protocol-kit using a passkey as a Signer. The passkey Signer is a contract compliant with EIP-1271 standards.

the passkey argument is an object with 2 keys:

  • rawId: The raw identifier of the passkey. See rawId docs
  • publicKey: The passkey’s public key, generated via the getPublicKey() method, necessary for generating X & Y coordinates. See getPublicKey() method docs
  const passkeyCredential = await navigator.credentials.create({ ... });

  const rawId = passkeyCredential.rawId;
  const publicKey = passkeyCredential.response.getPublicKey();

  const passkey = {
    rawId,
    publicKey
  };

  const safeWithPasskey = await Safe.create({
    provider: RPC_URL,
    signer: passkey,
    safeAddress
  });

Add a passkey owner

Now you can use the createAddOwnerTx method present in the protocol-kit using a passkey:

  const safeTransaction = await safe.createAddOwnerTx({
    passkey,
  });

This function returns the Safe transaction to add a passkey as owner and optionally change the threshold. If the passkey Signer contract has not been deployed, this function creates a batch that includes both the Signer contract deployment and the addOwner transaction.

isOwner function

The isOwner function now checks if a specific address or passkey is an owner of the current Safe:

  const safe = await Safe.create({
    provider: RPC_URL,
    safeAddress,
  });

  await safe.isOwner(passkey); // returns true or false

Sign a transaction using passkeys

  const safeWithPasskey = await Safe.create({
    provider: RPC_URL,
    signer: passkey,
    safeAddress,
  });

  const dumpTransaction = {
    to: safeAddress,
    value: "0",
    data: "0x",
  };

  const safeTransaction = await safeWithPasskey.createTransaction({
    transactions: [dumpTransaction],
  });

  const signedTransaction = await safeWithPasskey.signTransaction(
    safeTransaction
  );


// execution need to be handled via EOA

Added the WebAuthn Signer Factory Contract

This SafeWebAuthnSignerFactory contract is used to create a Signer and to get the address of a Signer.

@DaniSomoza DaniSomoza changed the base branch from main to development May 8, 2024 16:26
@DaniSomoza DaniSomoza changed the base branch from development to Abitype-1_3_0-safe-contract May 8, 2024 16:27
@DaniSomoza DaniSomoza self-assigned this May 9, 2024
@DaniSomoza DaniSomoza marked this pull request as ready for review May 9, 2024 14:19
const safeProvider = new SafeProvider({ provider: this.#provider, signer: this.#signer })
const safeProvider = new SafeProvider({
provider: this.#provider,
signer: this.#signer as string
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can we avoid the cast ?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done! I created a createSafeProvider util function to create a proper SafeProvider object based on the signer provided by the user.

I added this in the other PR (passkey-4337-support) to be able to use it also in the relay-kit. With this change the protocol-kit, the relay-kit and the account-abstraction-kit is compatible with passkeys.

@@ -29,6 +29,7 @@ import ModuleManager from './managers/moduleManager'
import OwnerManager from './managers/ownerManager'
import {
AddOwnerTxParams,
AddPasskeyOwnerTxParams,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why two separate types? Could we use an Union type instead?

Copy link
Contributor Author

@DaniSomoza DaniSomoza Jun 4, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I added two separate types to create guards:

export function isAddOwnerTxParams(
  params: AddOwnerTxParams | AddPasskeyOwnerTxParams
): params is AddOwnerTxParams {
  return (params as AddOwnerTxParams).ownerAddress !== undefined
}

export function isAddPasskeyOwnerTxParams(
  params: AddOwnerTxParams | AddPasskeyOwnerTxParams
): params is AddPasskeyOwnerTxParams {
  return (params as AddPasskeyOwnerTxParams).passkey !== undefined
}

packages/protocol-kit/src/Safe.ts Outdated Show resolved Hide resolved
const isPasskeySigner = await this.#safeProvider.isPasskeySigner()
if (isPasskeySigner) {
const txHash = await this.getTransactionHash(transaction)
const signedHash = await this.#safeProvider.signMessage(txHash)
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We usually do this.signHash(txHash) for signing tx hashes and will adjust the V and return the EthSafeSignature object

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

true! I updated this in the 4337 support PR:

    const isPasskeySigner = await this.#safeProvider.isPasskeySigner()

    if (isPasskeySigner) {
      const txHash = await this.getTransactionHash(transaction)

      signature = await this.signHash(txHash)

packages/protocol-kit/src/types/passkeys.ts Outdated Show resolved Hide resolved
const chainId = await safeProvider.getChainId()
const customContracts = contractNetworks?.[chainId.toString()]

const safeWebAuthnSignerFactoryContract = await getSafeWebAuthnSignerFactoryContract({
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This can be retrieved from the safeProvider right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

yep, but I updated this code to use the createSafeProvider util function to wrap all the logic about to create the safeProvider object to reuse it in all packages.

@@ -40,7 +41,7 @@ type SafeConfigWithPredictedSafeProps = {

export type SafeConfigProps = {
provider: SafeProviderConfig['provider']
signer?: SafeProviderConfig['signer']
signer?: string | passkeyArgType
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why this change? SafeProviderConfig signer should be the correct one right?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

constructor(
passkeyRawId: ArrayBuffer,
coordinates: PasskeyCoordinates,
safeWebAuthnSignerFactoryContract: SafeWebAuthnSignerFactoryContractImplementationType,
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

What about using the safeProvider as a param and retrieve the contract and external provider from there?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think that the best option is to avoid this, because the user can use the custom contracts and we have all this logic in the Safe.init() function.

So to avoid to include this customContracts logic inside of the SafeProvider, I think that we should keep this:

    const safeWebAuthnSignerFactoryContract = await getSafeWebAuthnSignerFactoryContract({
      safeProvider,
      safeVersion: '1.4.1',
      customContracts
    })

    const passkeySigner = await PasskeySigner.init(
      signer,
      safeWebAuthnSignerFactoryContract,
      safeProvider.getExternalProvider()
    )
    
     new SafeProvider({
      provider,
      signer: passkeySigner
    })

Copy link
Member

@yagopv yagopv left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we need an utility (buildPasskey) to build the object used with navigator.credentials.create

{
      pubKeyCredParams: [
        {
          // ECDSA w/ SHA-256: https://datatracker.ietf.org/doc/html/rfc8152#section-8.1
          alg: -7,
          type: 'public-key',
        },
      ],
      challenge: crypto.getRandomValues(new Uint8Array(32)),
      rp: {
        name: 'Safe Wallet',
      },
      user: {
        displayName: 'Safe Owner',
        id: crypto.getRandomValues(new Uint8Array(32)),
        name: 'safe-owner',
      },
      timeout: 60000,
      attestation: 'none',
    }

@dasanra dasanra changed the base branch from Abitype-1_3_0-safe-contract to development May 16, 2024 12:17
* @returns TRUE if the account is an owner
*/
async isOwner(owner: string | passkeyArgType): Promise<boolean> {
const isOwnerAddress = typeof owner === 'string'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The this.#safeProvider has an util to detect if this is an address. Consider to just query it. I dont think it will match eip3770 though.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

In this case we need to know if the owner param is a string or a passkeyArgType not an address.


const passkeySigner = await PasskeySigner.init(owner, webAuthnSignerFactoryContract, provider)

const ownerAddress = await passkeySigner.getAddress()
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I may be wrong but isnt all this chunk just the this.#safeProvider.getSignerAddress()?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

the this.#safeProvider might have been initialized with an EOA instead of a passkey. We need to ensure that the owner's address is created from the passkey provided by the user in the isOwner function parameter, not from the one in the this.#safeProvider.

provider,
signer
})
const isPasskeySigner = signer && typeof signer !== 'string'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Disclaimer: The comment has nothing to do with this line but wasnt the protocol kit supposed to have a getPasskeyCreationOptions or something like that for the people to have a schema on how to create a "safe-compliant" passkey or something?

@@ -62,7 +63,38 @@ class SafeFactory {
}: SafeFactoryInitConfig) {
this.#provider = provider
this.#signer = signer
this.#safeProvider = new SafeProvider({ provider, signer })
const isPasskeySigner = signer && typeof signer !== 'string'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider on extracting this to a local util since its reused.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done! I addressed this comment in this other PR. I created a util in called createSafeProvider to reuse all the logic behind this:

Captura de pantalla 2024-06-04 a las 19 20 29

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

After your suggestion in this comment, I create an static async init method in the SafeProvider instead of the createSafeProvider


export type SafeProviderConfig = {
/** signerOrProvider - Ethers signer or provider */
provider: Eip1193Provider | HttpTransport | SocketTransport
signer?: HexAddress | PrivateKey
signer?: HexAddress | PrivateKey | passkeyArgType
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Isnt this the SafeSigner above?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nope, in the SafeSigner the PasskeySigner is a different type

return this.#externalProvider.getSigner(this.signer)
if (typeof this.signer === 'string') {
// If the signer is not an Ethereum address, it should be a private key
if (this.signer && !ethers.isAddress(this.signer)) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think if it reached this point, we can assume that this.signer is defined. Please, remove the left-hand side conditionhere and below.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!


super(chainId, safeProvider, defaultAbi, safeVersion, customContractAddress, customContractAbi)

this.safeVersion = safeVersion
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Any particular reason why its not just this.safeVersion = '1.4.1'?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It is because super(...) call must be the first statement in the constructor. after super(...) call we can set the this.safeVersion value

}
})) as PublicKeyCredential & { response: AuthenticatorAssertionResponse }

if (!assertion || !assertion?.response?.authenticatorData) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I dont think you need the first verification since you are using safe navigation/elvis operator.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

done!

publicKey: {
challenge: data,
allowCredentials: [{ type: 'public-key', id: this.passkeyRawId }],
userVerification: 'required'
Copy link
Contributor

@leonardotc leonardotc May 31, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I imagine this would throw that weird prompt in the user's face and that is not something that the Dapp necessarily opted-in (since its not an option here). It should probably be added to the js-docs.

}

signTypedData(): Promise<string> {
throw new Error('Passkey Signers cannot sign signTypedData, they can only sign data.')
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Consider on changing the isTypedDataSigner as well (although that will probably change the error).

DaniSomoza and others added 2 commits June 12, 2024 18:15
* Fix existing unit tests

* Test case for initializing Safe4337Pack with a passkey signer

* Tests for signing a SafeOperation with passkey + sending to bundler

* Extend webauthnShim to initialize with a static private key
* Passkey private key must be specified via env variable
* Move logic to create a mocked passkey object to utils folder in protocol-kit

* Fix passkey tests in protocol-kit and use `createMockPasskey` function from utils

* Remove `.only` from test

* Add comment

* Add env variable `PASSKEY_PRIVATE_KEY`

* Fix `createMockPasskey` function JSDoc

* Allow to create mocked passkeys with a static private key

* Fix test for latest changes

Passkey signer is not being added to the owners of a 4337 Safe, but only the SafeWebAuthnSharedSigner address.

* Fix tests

* Remove duplicated file for passkey test utils

* Remove unnecessary `await`

* Fix account-abstraction-kit test

---------

Co-authored-by: Daniel <25051234+dasanra@users.noreply.github.com>
@coveralls
Copy link

coveralls commented Jun 17, 2024

Pull Request Test Coverage Report for Build 9545149597

Details

  • 50 of 51 (98.04%) changed or added relevant lines in 4 files are covered.
  • 5 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.6%) to 78.437%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts 32 33 96.97%
Files with Coverage Reduction New Missed Lines %
packages/relay-kit/src/packs/safe-4337/utils.ts 5 80.95%
Totals Coverage Status
Change from base Build 9485617547: 0.6%
Covered Lines: 817
Relevant Lines: 969

💛 - Coveralls

DaniSomoza and others added 2 commits June 18, 2024 13:38
* Added SAFE_WEBAUTHN_SHARED_SIGNER_ADDRESS as parameter in the addDummySignature
@coveralls
Copy link

coveralls commented Jun 19, 2024

Pull Request Test Coverage Report for Build 9582107333

Details

  • 50 of 51 (98.04%) changed or added relevant lines in 4 files are covered.
  • 5 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.6%) to 78.437%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts 32 33 96.97%
Files with Coverage Reduction New Missed Lines %
packages/relay-kit/src/packs/safe-4337/utils.ts 5 80.95%
Totals Coverage Status
Change from base Build 9485617547: 0.6%
Covered Lines: 817
Relevant Lines: 969

💛 - Coveralls

leonardotc and others added 5 commits June 20, 2024 10:40
* Add createSwapOwnerTx passkey tests

* Add remove owner tests
* add SafeWebAuthnSharedSigner Contract

* added passkey shared signer support to signHash

* Added unit tests
@coveralls
Copy link

coveralls commented Jul 10, 2024

Pull Request Test Coverage Report for Build 10524084006

Details

  • 55 of 59 (93.22%) changed or added relevant lines in 3 files are covered.
  • 5 unchanged lines in 1 file lost coverage.
  • Overall coverage increased (+0.3%) to 77.534%

Changes Missing Coverage Covered Lines Changed/Added Lines %
packages/relay-kit/src/packs/safe-4337/Safe4337Pack.ts 38 42 90.48%
Files with Coverage Reduction New Missed Lines %
packages/relay-kit/src/packs/safe-4337/utils.ts 5 80.95%
Totals Coverage Status
Change from base Build 10510384545: 0.3%
Covered Lines: 978
Relevant Lines: 1188

💛 - Coveralls


export type SafeProviderConfig = {
/** signerOrProvider - Ethers signer or provider */
provider: Eip1193Provider | HttpTransport | SocketTransport
signer?: HexAddress | PrivateKey
signer?: HexAddress | PrivateKey | PasskeySigner | PasskeyArgType
Copy link
Member

@yagopv yagopv Jul 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Confused here with the added PasskeySigner. This is to instantiate the SafeProvider with some signer from outside as an address, a privateKey or a passkey args object. Can this be instantiate with the PasskeySigner class?

In any case this should be added as well to the SafeSigner type above


constructor({ provider, signer }: SafeProviderConfig) {
provider: SafeProviderConfig['provider']
signer?: SafeSigner
Copy link
Member

@yagopv yagopv Jul 10, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would use the SafeProviderConfig['signer'] in all places and remove the SafeSigner type or add a new SafeEthereumProvider (for example) instead, so we would use:

provider: SafeProviderConfig['provider'],
signer: SafeProviderConfig['signer']

or

provider: SafeEthereumProvider,
signer: SafeSigner

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is only to make it cleaner as this two props are used in multiple places

DaniSomoza and others added 5 commits August 12, 2024 13:09
* refactor in Safe.ts to remove passkey shared signer logic

* Add getPasskeyOwnerAddress as util fn

* add createPasskeyDeploymentTransaction as a util function

* fix: use safeProvider instance

* fix isOwner test for v1.0.0

* add getPasskeyOwnerAddress tests

* Add dinamic addresses to the getPasskeyOwnerAddress tests
@@ -0,0 +1,50 @@
import Safe from '../../Safe'
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We are missing many global routes @safe-global/protocol-kit/Safe

@DaniSomoza DaniSomoza merged commit aaaf44c into development Aug 23, 2024
20 checks passed
@DaniSomoza DaniSomoza deleted the passkey-support branch August 23, 2024 11:24
@github-actions github-actions bot locked and limited conversation to collaborators Aug 23, 2024
@dasanra dasanra restored the passkey-support branch August 23, 2024 11:25
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

[Passkeys] Add passkey owner to an already existing Safe [Passkeys] Add compatibility to the protocol-kit
6 participants