Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/fresh-moons-bathe.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"permissionless": patch
---

Added passkeys support for all Safe versions and entrypoint 0.7 & 0.8
216 changes: 177 additions & 39 deletions packages/permissionless/accounts/safe/signUserOperation.ts
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
import { Signature } from "ox"
import {
type Account,
type Address,
Expand All @@ -8,41 +9,127 @@ import {
type Transport,
type UnionPartialBy,
type WalletClient,
concat,
concatHex,
decodeAbiParameters,
encodeAbiParameters,
encodePacked
encodePacked,
hashTypedData
} from "viem"
import type { UserOperation } from "viem/account-abstraction"
import type { UserOperation, WebAuthnAccount } from "viem/account-abstraction"
import { toOwner } from "../../utils/index.js"
import type { EthereumProvider } from "../../utils/toOwner.js"
import {
EIP712_SAFE_OPERATION_TYPE_V06,
EIP712_SAFE_OPERATION_TYPE_V07,
type SafeVersion,
getDefaultAddresses,
getPaymasterAndData
getPaymasterAndData,
isWebAuthnAccount
} from "./toSafeSmartAccount.js"

export const concatSignatures = (
signatures: { signer: Address; data: Hex; dynamic: boolean }[]
) => {
signatures.sort((left, right) =>
left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())
)

const SIGNATURE_LENGTH_BYTES = 65
let signatureBytes = "0x"
let dynamicBytes = ""

for (const sig of signatures) {
if (sig.dynamic) {
/*
A contract signature has a static part of 65 bytes and the dynamic part that needs to be appended
at the end of signature bytes.
The signature format is
Signature type == 0
Constant part: 65 bytes
{32-bytes signature verifier}{32-bytes dynamic data position}{1-byte signature type}
Dynamic part (solidity bytes): 32 bytes + signature data length
{32-bytes signature length}{bytes signature data}
*/
const dynamicPartPosition = (
signatures.length * SIGNATURE_LENGTH_BYTES +
dynamicBytes.length / 2
)
.toString(16)
.padStart(64, "0")
const dynamicPartLength = (sig.data.slice(2).length / 2)
.toString(16)
.padStart(64, "0")
const staticSignature = `${sig.signer.slice(2).padStart(64, "0")}${dynamicPartPosition}00`
const dynamicPartWithLength = `${dynamicPartLength}${sig.data.slice(2)}`
signatureBytes += staticSignature
dynamicBytes += dynamicPartWithLength
} else {
signatureBytes += sig.data.slice(2)
}
}

signatureBytes += dynamicBytes

return signatureBytes as Hex
}

export const getWebAuthnSignature = async ({
owner,
hash
}: {
owner: WebAuthnAccount
hash: Hex
}) => {
const { signature: signatureData, webauthn } = await owner.sign({
hash
})

const signature = Signature.fromHex(signatureData)

const match = webauthn.clientDataJSON.match(
/^\{"type":"webauthn.get","challenge":"[A-Za-z0-9\-_]{43}",(.*)\}$/
)

if (!match) {
throw new Error("challenge not found in client data JSON")
}

const [, fields] = match

return encodeAbiParameters(
[
{ name: "authenticatorData", type: "bytes" },
{ name: "clientDataJSON", type: "string" },
{ name: "signature", type: "uint256[2]" }
],
[
webauthn.authenticatorData,
fields,
[BigInt(signature.r), BigInt(signature.s)]
]
)
}

export async function signUserOperation(
parameters: UnionPartialBy<UserOperation, "sender"> & {
version: SafeVersion
entryPoint: {
address: Address
version: "0.6" | "0.7"
}
owners: Account[]
owners: (Account | WebAuthnAccount)[]
account: OneOf<
| EthereumProvider
| WalletClient<Transport, Chain | undefined, Account>
| LocalAccount
| WebAuthnAccount
>
chainId: number
signatures?: Hex
validAfter?: number
validUntil?: number
safe4337ModuleAddress?: Address
safeWebAuthnSharedSignerAddress?: Address
}
) {
const {
Expand Down Expand Up @@ -102,38 +189,93 @@ export async function signUserOperation(
})
}

const localOwners = [
await toOwner({
owner: account as OneOf<LocalAccount | EthereumProvider>
})
]
const localOwner = isWebAuthnAccount(account)
? account
: await toOwner({
owner: account
})

const signer = isWebAuthnAccount(localOwner)
? parameters.safeWebAuthnSharedSignerAddress
: localOwner.address

let unPackedSignatures: readonly { signer: Address; data: Hex }[] = []
if (!signer) {
throw new Error("no signer found")
}

let unPackedSignatures: readonly {
signer: Address
data: Hex
dynamic: boolean
}[] = []

if (existingSignatures) {
const decoded = decodeAbiParameters(
[
{
components: [
{ type: "address", name: "signer" },
{ type: "bytes", name: "data" }
],
name: "signatures",
type: "tuple[]"
}
],
existingSignatures
)
try {
const decoded = decodeAbiParameters(
[
{
components: [
{ type: "address", name: "signer" },
{ type: "bytes", name: "data" },
{ type: "bool", name: "dynamic" }
],
name: "signatures",
type: "tuple[]"
}
],
existingSignatures
)

unPackedSignatures = decoded[0]
} catch {
const decoded = decodeAbiParameters(
[
{
components: [
{ type: "address", name: "signer" },
{ type: "bytes", name: "data" }
],
name: "signatures",
type: "tuple[]"
}
],
existingSignatures
)

unPackedSignatures = decoded[0]
unPackedSignatures = decoded[0].map((sig) => ({
...sig,
dynamic: false
}))
}
}

const signatures: { signer: Address; data: Hex }[] = [
const signatures: { signer: Address; data: Hex; dynamic: boolean }[] = [
...unPackedSignatures,
...(await Promise.all(
localOwners.map(async (localOwner) => ({
signer: localOwner.address,
data: await localOwner.signTypedData({
{
signer,
dynamic: isWebAuthnAccount(localOwner),
data: await (async () => {
if (isWebAuthnAccount(localOwner)) {
const safeHash = hashTypedData({
domain: {
chainId,
verifyingContract: safe4337ModuleAddress
},
types:
entryPoint.version === "0.6"
? EIP712_SAFE_OPERATION_TYPE_V06
: EIP712_SAFE_OPERATION_TYPE_V07,
primaryType: "SafeOp",
message: message
})

return getWebAuthnSignature({
owner: localOwner,
hash: safeHash
})
}

return localOwner.signTypedData({
domain: {
chainId,
verifyingContract: safe4337ModuleAddress
Expand All @@ -145,8 +287,8 @@ export async function signUserOperation(
primaryType: "SafeOp",
message: message
})
}))
))
})()
}
]

if (signatures.length !== owners.length) {
Expand All @@ -155,7 +297,8 @@ export async function signUserOperation(
{
components: [
{ type: "address", name: "signer" },
{ type: "bytes", name: "data" }
{ type: "bytes", name: "data" },
{ type: "bool", name: "dynamic" }
],
name: "signatures",
type: "tuple[]"
Expand All @@ -165,13 +308,8 @@ export async function signUserOperation(
)
}

signatures.sort((left, right) =>
left.signer.toLowerCase().localeCompare(right.signer.toLowerCase())
)
const signatureBytes = concat(signatures.map((sig) => sig.data))

return encodePacked(
["uint48", "uint48", "bytes"],
[validAfter, validUntil, signatureBytes]
[validAfter, validUntil, concatSignatures(signatures)]
)
}
Loading
Loading