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
10 changes: 8 additions & 2 deletions packages/wallet/core/src/bundler/bundlers/pimlico.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,17 @@ export class PimlicoBundler implements Bundler {

public readonly provider: Provider.Provider
public readonly bundlerRpcUrl: string
private readonly fetcher: typeof fetch

constructor(bundlerRpcUrl: string, provider: Provider.Provider | string) {
constructor(bundlerRpcUrl: string, provider: Provider.Provider | string, fetcher?: typeof fetch) {
this.id = `pimlico-erc4337-${bundlerRpcUrl}`
this.provider = typeof provider === 'string' ? Provider.from(RpcTransport.fromHttp(provider)) : provider
this.bundlerRpcUrl = bundlerRpcUrl
const resolvedFetch = fetcher ?? (globalThis as any).fetch
if (!resolvedFetch) {
throw new Error('fetch is not available')
}
this.fetcher = resolvedFetch
}

async isAvailable(entrypoint: Address.Address, chainId: number): Promise<boolean> {
Expand Down Expand Up @@ -165,7 +171,7 @@ export class PimlicoBundler implements Bundler {

private async bundlerRpc<T>(method: string, params: any[]): Promise<T> {
const body = JSON.stringify({ jsonrpc: '2.0', id: 1, method, params })
const res = await fetch(this.bundlerRpcUrl, {
const res = await this.fetcher(this.bundlerRpcUrl, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body,
Expand Down
68 changes: 68 additions & 0 deletions packages/wallet/core/src/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
export type StorageLike = {
getItem: (key: string) => string | null
setItem: (key: string, value: string) => void
removeItem: (key: string) => void
}

export type CryptoLike = {
subtle: SubtleCrypto
getRandomValues: <T extends ArrayBufferView>(array: T) => T
}

export type TextEncodingLike = {
TextEncoder: typeof TextEncoder
TextDecoder: typeof TextDecoder
}

export type CoreEnv = {
fetch?: typeof fetch
crypto?: CryptoLike
storage?: StorageLike
indexedDB?: IDBFactory
text?: Partial<TextEncodingLike>
}

function isStorageLike(value: unknown): value is StorageLike {
if (!value || typeof value !== 'object') return false
const candidate = value as StorageLike
return (
typeof candidate.getItem === 'function' &&
typeof candidate.setItem === 'function' &&
typeof candidate.removeItem === 'function'
)
}

export function resolveCoreEnv(env?: CoreEnv): CoreEnv {
const globalObj = globalThis as any
const windowObj = typeof window !== 'undefined' ? window : (globalObj.window ?? {})
let storage: StorageLike | undefined
let text: Partial<TextEncodingLike> | undefined

if (isStorageLike(env?.storage)) {
storage = env.storage
} else if (isStorageLike(windowObj.localStorage)) {
storage = windowObj.localStorage
} else if (isStorageLike(globalObj.localStorage)) {
storage = globalObj.localStorage
}

if (env?.text) {
if (!env.text.TextEncoder || !env.text.TextDecoder) {
throw new Error('env.text must provide both TextEncoder and TextDecoder')
}
text = env.text
} else {
text = {
TextEncoder: windowObj.TextEncoder ?? globalObj.TextEncoder,
TextDecoder: windowObj.TextDecoder ?? globalObj.TextDecoder,
}
}

return {
fetch: env?.fetch ?? windowObj.fetch ?? globalObj.fetch,
crypto: env?.crypto ?? windowObj.crypto ?? globalObj.crypto,
storage,
indexedDB: env?.indexedDB ?? windowObj.indexedDB ?? globalObj.indexedDB,
text,
}
}
1 change: 1 addition & 0 deletions packages/wallet/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ export * as State from './state/index.js'
export * as Bundler from './bundler/index.js'
export * as Envelope from './envelope.js'
export * as Utils from './utils/index.js'
export * from './env.js'
export {
type ExplicitSessionConfig,
type ExplicitSession,
Expand Down
24 changes: 20 additions & 4 deletions packages/wallet/core/src/signers/passkey.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,19 +5,27 @@ import { WebAuthnP256 } from 'ox'
import { State } from '../index.js'
import { SapientSigner, Witnessable } from './index.js'

export type WebAuthnLike = Pick<typeof WebAuthnP256, 'createCredential' | 'sign'>

export type PasskeyOptions = {
extensions: Pick<Extensions.Extensions, 'passkeys'>
publicKey: Extensions.Passkeys.PublicKey
credentialId: string
embedMetadata?: boolean
metadata?: Extensions.Passkeys.PasskeyMetadata
webauthn?: WebAuthnLike
}

export type CreatePasskeyOptions = {
stateProvider?: State.Provider
requireUserVerification?: boolean
credentialName?: string
embedMetadata?: boolean
webauthn?: WebAuthnLike
}

export type FindPasskeyOptions = {
webauthn?: WebAuthnLike
}

export type WitnessMessage = {
Expand Down Expand Up @@ -45,6 +53,7 @@ export class Passkey implements SapientSigner, Witnessable {
public readonly imageHash: Hex.Hex
public readonly embedMetadata: boolean
public readonly metadata?: Extensions.Passkeys.PasskeyMetadata
private readonly webauthn: WebAuthnLike

constructor(options: PasskeyOptions) {
this.address = options.extensions.passkeys
Expand All @@ -53,13 +62,15 @@ export class Passkey implements SapientSigner, Witnessable {
this.embedMetadata = options.embedMetadata ?? false
this.imageHash = Extensions.Passkeys.rootFor(options.publicKey)
this.metadata = options.metadata
this.webauthn = options.webauthn ?? WebAuthnP256
}

static async loadFromWitness(
stateReader: State.Reader,
extensions: Pick<Extensions.Extensions, 'passkeys'>,
wallet: Address.Address,
imageHash: Hex.Hex,
options?: FindPasskeyOptions,
) {
// In the witness we will find the public key, and may find the credential id
const witness = await stateReader.getWitnessForSapient(wallet, extensions.passkeys, imageHash)
Expand Down Expand Up @@ -90,13 +101,15 @@ export class Passkey implements SapientSigner, Witnessable {
publicKey: message.publicKey,
embedMetadata: decodedSignature.embedMetadata,
metadata,
webauthn: options?.webauthn,
})
}

static async create(extensions: Pick<Extensions.Extensions, 'passkeys'>, options?: CreatePasskeyOptions) {
const webauthn = options?.webauthn ?? WebAuthnP256
const name = options?.credentialName ?? `Sequence (${Date.now()})`

const credential = await WebAuthnP256.createCredential({
const credential = await webauthn.createCredential({
user: {
name,
},
Expand All @@ -120,6 +133,7 @@ export class Passkey implements SapientSigner, Witnessable {
},
embedMetadata: options?.embedMetadata,
metadata,
webauthn,
})

if (options?.stateProvider) {
Expand All @@ -132,8 +146,10 @@ export class Passkey implements SapientSigner, Witnessable {
static async find(
stateReader: State.Reader,
extensions: Pick<Extensions.Extensions, 'passkeys'>,
options?: FindPasskeyOptions,
): Promise<Passkey | undefined> {
const response = await WebAuthnP256.sign({ challenge: Hex.random(32) })
const webauthn = options?.webauthn ?? WebAuthnP256
const response = await webauthn.sign({ challenge: Hex.random(32) })
if (!response.raw) throw new Error('No credential returned')

const authenticatorDataBytes = Bytes.fromHex(response.metadata.authenticatorData)
Expand Down Expand Up @@ -218,7 +234,7 @@ export class Passkey implements SapientSigner, Witnessable {
console.warn('Multiple signers found for passkey', flattened)
}

return Passkey.loadFromWitness(stateReader, extensions, flattened[0]!.wallet, flattened[0]!.imageHash)
return Passkey.loadFromWitness(stateReader, extensions, flattened[0]!.wallet, flattened[0]!.imageHash, options)
}

async signSapient(
Expand All @@ -234,7 +250,7 @@ export class Passkey implements SapientSigner, Witnessable {

const challenge = Hex.fromBytes(Payload.hash(wallet, chainId, payload))

const response = await WebAuthnP256.sign({
const response = await this.webauthn.sign({
challenge,
credentialId: this.credentialId,
userVerification: this.publicKey.requireUserVerification ? 'required' : 'discouraged',
Expand Down
117 changes: 103 additions & 14 deletions packages/wallet/core/src/signers/pk/encrypted.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Hex, Address, PublicKey, Secp256k1, Bytes } from 'ox'
import { resolveCoreEnv, type CoreEnv, type CryptoLike, type StorageLike, type TextEncodingLike } from '../../env.js'
import { PkStore } from './index.js'

export interface EncryptedData {
Expand All @@ -17,6 +18,7 @@ export class EncryptedPksDb {
constructor(
private readonly localStorageKeyPrefix: string = 'e_pk_key_',
tableName: string = 'e_pk',
private readonly env?: CoreEnv,
) {
this.tableName = tableName
}
Expand All @@ -25,9 +27,59 @@ export class EncryptedPksDb {
return `pk_${address.toLowerCase()}`
}

private getIndexedDB(): IDBFactory {
const globalObj = globalThis as any
const indexedDb = this.env?.indexedDB ?? globalObj.indexedDB ?? globalObj.window?.indexedDB
if (!indexedDb) {
throw new Error('indexedDB is not available')
}
return indexedDb
}

private getStorage(): StorageLike {
const storage = resolveCoreEnv(this.env).storage
if (!storage) {
throw new Error('storage is not available')
}
return storage
}

private getCrypto(): CryptoLike {
const globalObj = globalThis as any
const crypto = this.env?.crypto ?? globalObj.crypto ?? globalObj.window?.crypto
if (!crypto?.subtle || !crypto?.getRandomValues) {
throw new Error('crypto.subtle is not available')
}
return crypto
}

private getTextEncoderCtor(): TextEncodingLike['TextEncoder'] {
const globalObj = globalThis as any
if (this.env?.text && (!this.env.text.TextEncoder || !this.env.text.TextDecoder)) {
throw new Error('env.text must provide both TextEncoder and TextDecoder')
}
const encoderCtor = this.env?.text?.TextEncoder ?? globalObj.TextEncoder ?? globalObj.window?.TextEncoder
if (!encoderCtor) {
throw new Error('TextEncoder is not available')
}
return encoderCtor
}

private getTextDecoderCtor(): TextEncodingLike['TextDecoder'] {
const globalObj = globalThis as any
if (this.env?.text && (!this.env.text.TextEncoder || !this.env.text.TextDecoder)) {
throw new Error('env.text must provide both TextEncoder and TextDecoder')
}
const decoderCtor = this.env?.text?.TextDecoder ?? globalObj.TextDecoder ?? globalObj.window?.TextDecoder
if (!decoderCtor) {
throw new Error('TextDecoder is not available')
}
return decoderCtor
}

private openDB(): Promise<IDBDatabase> {
return new Promise((resolve, reject) => {
const request = indexedDB.open(this.dbName, this.dbVersion)
const request = this.getIndexedDB().open(this.dbName, this.dbVersion)
request.onupgradeneeded = () => {
const db = request.result
if (!db.objectStoreNames.contains(this.tableName)) {
Expand Down Expand Up @@ -73,7 +125,11 @@ export class EncryptedPksDb {
}

async generateAndStore(): Promise<EncryptedData> {
const encryptionKey = await window.crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
const crypto = this.getCrypto()
const storage = this.getStorage()
const TextEncoderCtor = this.getTextEncoderCtor()

const encryptionKey = await crypto.subtle.generateKey({ name: 'AES-GCM', length: 256 }, true, [
'encrypt',
'decrypt',
])
Expand All @@ -84,13 +140,13 @@ export class EncryptedPksDb {
const address = Address.fromPublicKey(publicKey)
const keyPointer = this.localStorageKeyPrefix + address

const exportedKey = await window.crypto.subtle.exportKey('jwk', encryptionKey)
window.localStorage.setItem(keyPointer, JSON.stringify(exportedKey))
const exportedKey = await crypto.subtle.exportKey('jwk', encryptionKey)
storage.setItem(keyPointer, JSON.stringify(exportedKey))

const encoder = new TextEncoder()
const encoder = new TextEncoderCtor()
const encodedPk = encoder.encode(privateKey)
const iv = window.crypto.getRandomValues(new Uint8Array(12))
const encryptedBuffer = await window.crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, encodedPk)
const iv = crypto.getRandomValues(new Uint8Array(12))
const encryptedBuffer = await crypto.subtle.encrypt({ name: 'AES-GCM', iv }, encryptionKey, encodedPk)

const encrypted: EncryptedData = {
iv,
Expand All @@ -113,7 +169,7 @@ export class EncryptedPksDb {
async getEncryptedPkStore(address: Address.Address): Promise<EncryptedPkStore | undefined> {
const entry = await this.getEncryptedEntry(address)
if (!entry) return
return new EncryptedPkStore(entry)
return new EncryptedPkStore(entry, this.env)
}

async listAddresses(): Promise<Address.Address[]> {
Expand All @@ -125,12 +181,41 @@ export class EncryptedPksDb {
const dbKey = this.computeDbKey(address)
await this.putData(dbKey, undefined)
const keyPointer = this.localStorageKeyPrefix + address
window.localStorage.removeItem(keyPointer)
this.getStorage().removeItem(keyPointer)
}
}

export class EncryptedPkStore implements PkStore {
constructor(private readonly encrypted: EncryptedData) {}
constructor(
private readonly encrypted: EncryptedData,
private readonly env?: CoreEnv,
) {}

private getStorage(): StorageLike {
const storage = resolveCoreEnv(this.env).storage
if (!storage) {
throw new Error('storage is not available')
}
return storage
}

private getCrypto(): CryptoLike {
const globalObj = globalThis as any
const crypto = this.env?.crypto ?? globalObj.crypto ?? globalObj.window?.crypto
if (!crypto?.subtle) {
throw new Error('crypto.subtle is not available')
}
return crypto
}

private getTextDecoderCtor(): TextEncodingLike['TextDecoder'] {
const globalObj = globalThis as any
const decoderCtor = this.env?.text?.TextDecoder ?? globalObj.TextDecoder ?? globalObj.window?.TextDecoder
if (!decoderCtor) {
throw new Error('TextDecoder is not available')
}
return decoderCtor
}

address(): Address.Address {
return this.encrypted.address
Expand All @@ -141,16 +226,20 @@ export class EncryptedPkStore implements PkStore {
}

async signDigest(digest: Bytes.Bytes): Promise<{ r: bigint; s: bigint; yParity: number }> {
const keyJson = window.localStorage.getItem(this.encrypted.keyPointer)
const storage = this.getStorage()
const crypto = this.getCrypto()
const TextDecoderCtor = this.getTextDecoderCtor()

const keyJson = storage.getItem(this.encrypted.keyPointer)
if (!keyJson) throw new Error('Encryption key not found in localStorage')
const jwk = JSON.parse(keyJson)
const encryptionKey = await window.crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['decrypt'])
const decryptedBuffer = await window.crypto.subtle.decrypt(
const encryptionKey = await crypto.subtle.importKey('jwk', jwk, { name: 'AES-GCM' }, false, ['decrypt'])
const decryptedBuffer = await crypto.subtle.decrypt(
{ name: 'AES-GCM', iv: this.encrypted.iv },
encryptionKey,
this.encrypted.data,
)
const decoder = new TextDecoder()
const decoder = new TextDecoderCtor()
const privateKey = decoder.decode(decryptedBuffer) as Hex.Hex
return Secp256k1.sign({ payload: digest, privateKey })
}
Expand Down
Loading