IBExWalletAPI
Advanced

Passkey vs KDF Signers

Detailed Explanation: Difference between Passkeys and KDF for Safe Creation

Overview

The main issue is that passkeys and KDF work differently at the signing level:

  • Passkeys: The server can sign directly via WebAuthn (the browser/device signs)
  • KDF: The server only has the public address, the private key remains on the client side

1. PASSKEY Mode - How it currently works

Step 1: Signer creation (line 843 of passkey.ts)

const safeSigner = await Safe.createPasskeySigner({
    rawId: base64UrlToArrayBuffer(credential.id),
    response: {
        getPublicKey() {
            const raw = base64UrlToArrayBuffer(response.response.publicKey);
            return ensureSpkiP256FromPossibleRaw(raw);
        },
    },
})

What Safe.createPasskeySigner() does:

  • Creates a signer object that can sign directly via WebAuthn
  • The signer contains the WebAuthn credential (public key + ID)
  • When safeSigner.sign() is called, it uses WebAuthn to sign (via the browser/device)

Important: This signer is "real" - it can sign immediately.

Step 2: Safe creation (line 972 of passkey.ts)

const s = await getSafe(safeSigner, undefined, cid)

What getSafe() does:

  • Uses Safe4337Pack.init() with the safeSigner
  • The Safe Global can use this signer to:
    • Calculate the Safe address (deterministic from the signer)
    • Check if the Safe is deployed
    • Sign transactions (via WebAuthn)

Step 3: Storage in the DB (line 1141 of passkey.ts)

data: {
    credential: { id: credential.id, publicKey: credential.publicKey },
    safe: {
        signer: safeSigner  // ← The full signer is stored
    }
}

Why store the signer:

  • During Safe operations (POST /v1.2/safes/operations), we retrieve signer.data.safe.signer (line 1650 of safesOperations.ts)
  • This signer is used to sign Safe transactions

Step 4: Usage during operations (line 1650 of safesOperations.ts)

const safeSigner = signer.data.safe.signer;  // Retrieves the stored signer
const { safe4337Pack } = await getSafe(safeSigner, paymaster, resolvedChainId);
// The safe4337Pack can now sign transactions via WebAuthn

Summary for Passkeys:

  1. ✅ The signer can sign directly (via WebAuthn)
  2. ✅ The Safe is created with this signer
  3. ✅ The signer is stored in the DB
  4. ✅ Operations use this signer to sign

2. KDF Mode - How it currently works (PROBLEM)

Step 1: Signature Verification (line 829 of v1.2/auth.ts)

const signatureOk = verifyEoaSignature(message, signature, publicKey);

What happens:

  • The client derives the private key from the KDF secret (client-side)
  • The client signs the challenge with this private key
  • The client sends the signature to the server
  • The server verifies the signature but does not have the private key

Important: The server only has:

  • publicKey (EOA address) - 0x3cd6ca93356d4d010442C50011D805B66ffcc8f6
  • The challenge signature (proof that the client has the private key)
  • BUT NOT the private key itself

Step 2: Signer creation (line 854 of v1.2/auth.ts)

await prisma.signer.upsert({
    where: { id: signerId },
    create: {
        id: signerId,  // = publicKey (EOA address)
        type: SignerType.EOA,
        data: {
            flow: PIN_KDF_FLOW,
            salt,
            saltVersion,
            kdf,
            kdfParamsVersion,
            serverKeyId: serverKeyId || PIN_KDF_SERVER_KEY_ID
            // ⚠️ NO data.safe.signer !
        }
    }
});

Problem: The signer is created but:

  • ❌ There is no data.safe.signer
  • ❌ The Safe is not created
  • ❌ We cannot use this signer to create a Safe because we don't have the private key

Step 3: Returning the response (line 893 of v1.2/auth.ts)

return {
    ...tokens,
    flow: PIN_KDF_FLOW,
    signerBound: true,
    signerVersion: 1,
    publicKey: signerId,
    safeAddress: {}  // ← Empty! The Safe does not exist
};

Result: The Safe is not created, thus safeAddress: \{\}


3. Proposed solution for KDF - "Virtual" Signer

Concept: Virtual signer vs real signer

Real signer (Passkeys):

  • Can sign directly
  • Contains the necessary information to sign (WebAuthn credential)
  • Used to create the Safe AND to sign transactions

Virtual signer (KDF/Email):

  • Can not sign directly (no private key)
  • Only contains the EOA address
  • Used only to create the Safe (calculate the address)
  • The real signer (with private key) will be used during operations

Step 1: Create a virtual signer from the EOA address

// For KDF, create a "virtual" signer that stores the address
function createEOASignerFromAddress(address: string) {
    // Option 1: Create a minimal signer object containing the address
    return {
        address: address,
        type: 'EOA',
        // This signer cannot sign, but can be used to:
        // - Calculate the Safe address (deterministic from the EOA address)
        // - Check if the Safe is deployed
    };
    
    // Option 2: Use Safe4337Pack with the address directly
    // (needs to be checked if Safe Global supports this)
}

Important: This virtual signer:

  • ✅ Can be used by getSafe() to calculate the Safe address
  • ✅ Can be used to check if the Safe is deployed
  • Cannot sign transactions (no private key)

Step 2: Create the Safe with the virtual signer

// Create the virtual signer
const virtualSigner = createEOASignerFromAddress(publicKey);
 
// Create the Safe with this virtual signer
const s = await getSafe(virtualSigner, undefined, chainId);
// getSafe() will:
// - Calculate the Safe address (deterministic from the EOA address)
// - Check if the Safe is deployed
// - Return the Safe address

Note: getSafe() does not need to sign to calculate the Safe address. It just needs to know what the signer will be (EOA address).

Step 3: Store the virtual signer in the DB

await prisma.signer.upsert({
    where: { id: signerId },
    create: {
        id: signerId,
        type: SignerType.EOA,
        data: {
            flow: PIN_KDF_FLOW,
            salt,
            saltVersion,
            kdf,
            kdfParamsVersion,
            serverKeyId: serverKeyId || PIN_KDF_SERVER_KEY_ID,
            safe: {
                signer: virtualSigner  // ← Store the virtual signer
            }
        }
    }
});

Important: We store the virtual signer in data.safe.signer, just like for passkeys.

Step 4: Create the Safe in the DB

await tx.safe.upsert({
    where: { address_blockchainId: { address: s.address, blockchainId: chainId } },
    create: {
        address: s.address,
        threshold: 1,
        Signer: { connect: { id: signerId } },
        Chains: { connect: { id: chainId } }
    }
});

Result: The Safe is now created and registered in the DB.

Step 5: Usage during operations - The Problem

During a Safe operation (POST /v1.2/safes/operations), the current code does:

const safeSigner = signer.data.safe.signer;  // Retrieves the virtual signer
const { safe4337Pack } = await getSafe(safeSigner, paymaster, resolvedChainId);
// ⚠️ PROBLEM: safe4337Pack cannot sign because the virtual signer does not have the private key

Solution: The code must be modified for KDF/Email:

const safeSigner = signer.data.safe.signer;
const signerType = signer.type;
 
if (signerType === SignerType.EOA || signerType === SignerType.EMAIL_TOKEN) {
    // For KDF/Email, the client must provide the signature in the request
    // We cannot use safeSigner to sign directly
    
    // Option 1: The client signs client-side and sends the signature
    // Option 2: Create an "on-demand" signer from the provided signature
    // Option 3: Use a different signing mechanism for KDF/Email
} else {
    // For passkeys, use the signer normally
    const { safe4337Pack } = await getSafe(safeSigner, paymaster, resolvedChainId);
}

4. Visual Comparison

Passkeys (Real Signer)

┌─────────────────────────────────────────┐
│  Client (browser)                        │
│  ┌──────────────────────────────────┐    │
│  │ WebAuthn Credential              │    │
│  │ - ID: base64...                  │    │
│  │ - PublicKey: SPKI DER            │    │
│  └──────────────────────────────────┘    │
└─────────────────────────────────────────┘

           │ POST /sign-up with credential

┌─────────────────────────────────────────┐
│  Server                                 │
│  ┌──────────────────────────────────┐    │
│  │ Safe.createPasskeySigner()       │    │
│  │ → REAL Signer                    │    │
│  │   - Can sign via WebAuthn        │    │
│  │   - Stored in data.safe.signer   │    │
│  └──────────────────────────────────┘    │
│           │                               │
│           │ getSafe(safeSigner)           │
│           ▼                               │
│  ┌──────────────────────────────────┐    │
│  │ Safe created                     │    │
│  │ - Address calculated             │    │
│  │ - Can sign transactions          │    │
│  └──────────────────────────────────┘    │
└─────────────────────────────────────────┘

KDF (Virtual Signer)

┌─────────────────────────────────────────┐
│  Client                                  │
│  ┌──────────────────────────────────┐    │
│  │ KDF Secret (PIN/password)        │    │
│  │ → Derives private key            │    │
│  │ → Derives publicKey (address)    │    │
│  │ → Signs the challenge            │    │
│  └──────────────────────────────────┘    │
│           │                               │
│           │ POST /sign-up with:           │
│           │ - publicKey (address)         │
│           │ - signature (proof)           │
│           │ ⚠️ NOT the private key         │
└───────────┼───────────────────────────────┘


┌─────────────────────────────────────────┐
│  Server                                 │
│  ┌──────────────────────────────────┐    │
│  │ Verifies signature               │    │
│  │ ✅ Valid signature                │    │
│  │ ⚠️ But no private key             │    │
│  └──────────────────────────────────┘    │
│           │                               │
│           │ createEOASignerFromAddress()  │
│           ▼                               │
│  ┌──────────────────────────────────┐    │
│  │ VIRTUAL Signer                   │    │
│  │ - Contains EOA address           │    │
│  │ - ⚠️ Cannot sign                  │    │
│  │ - Stored in data.safe.signer     │    │
│  └──────────────────────────────────┘    │
│           │                               │
│           │ getSafe(virtualSigner)         │
│           ▼                               │
│  ┌──────────────────────────────────┐    │
│  │ Safe created                     │    │
│  │ - Address calculated             │    │
│  │ - ⚠️ Cannot sign                  │    │
│  │   (needs the real signer)        │    │
│  └──────────────────────────────────┘    │
└─────────────────────────────────────────┘

Safe Operations - Difference

Passkeys:

POST /v1.2/safes/operations

Retrieves signer.data.safe.signer (real signer)

getSafe(safeSigner) → safe4337Pack

safe4337Pack.sign() → Signature via WebAuthn ✅

KDF (current - does not work):

POST /v1.2/safes/operations

Retrieves signer.data.safe.signer (virtual signer)

getSafe(safeSigner) → safe4337Pack

safe4337Pack.sign() → ❌ ERROR: No private key

KDF (proposed - to be implemented):

POST /v1.2/safes/operations (preparation)

Retrieves signer.data.safe.signer (virtual signer)

Creates the Safe transaction (userOp) with getSafe()

Calculates userOpHash = operation.getHash()

Returns userOpHash to client (instead of credentialRequestOptions)

Client derives private key → Signs userOpHash → Sends EOA signature

PUT /v1.2/safes/operations (execution)

Server receives EOA signature of userOpHash

Server adds the signature to userOp (like addSignature() for passkeys)

Server executes Safe transaction ✅

Important: The client's signature (KDF) must sign the userOpHash (just like the passkey signs via WebAuthn), which allows us to validate the Safe transaction in the same way. The only difference is:

  • Passkeys: WebAuthn signature (via credential)
  • KDF: EOA signature (via derived private key)

5. Questions to Resolve

Question 1: How to create an EOA signer from the address?

Option A: Create a minimal object

function createEOASignerFromAddress(address: string) {
    return {
        address: address,
        type: 'EOA'
    };
}

Option B: Use Safe Global with the address directly

// Need to check if Safe4337Pack.init() accepts an address directly
const safe4337Pack = await Safe4337Pack.init({
    provider: url,
    signer: address,  // Pass the address directly
    options: { threshold: 1, owners: [address] }
});

Option C: Create a "mock" signer that implements the Safe interface

// Create a signer that implements the necessary methods for getSafe()
// but cannot sign

Question 2: How to manage Safe operations for KDF/Email?

Answer: The client must sign the userOpHash (like the passkey), but with an EOA signature instead of a WebAuthn signature.

Proposed Flow:

  1. POST /v1.2/safes/operations (preparation - identical to passkeys):

    • Creates the Safe transaction (userOp)
    • Calculates userOpHash = operation.getHash()
    • For passkeys: Returns credentialRequestOptions (WebAuthn)
    • For KDF/Email: Returns userOpHash directly (or in a similar format)
  2. PUT /v1.2/safes/operations (execution - different for KDF/Email):

    • For passkeys: The client sends credential (WebAuthn signature)
    • For KDF/Email: The client sends signature (EOA signature of the userOpHash)
    • The server adds the signature to the userOp (like addSignature() for passkeys)
    • The server executes the transaction

Important: The EOA signature must sign the same userOpHash as the WebAuthn signature, ensuring both methods validate the Safe transaction equivalently.

Question 3: Should the virtual signer be stored in data.safe.signer?

Answer: Yes, to maintain consistency with passkeys. But the operations code must be modified to detect the signer type and handle it differently.


6. Recommendation

  1. Create the virtual signer from the EOA address for KDF/Email
  2. Create the Safe with this virtual signer (to calculate the address)
  3. Store the virtual signer in data.safe.signer (like passkeys)
  4. Modify the operations code to detect the signer type:
    • If SignerType.PASSKEY: use the signer normally
    • If SignerType.EOA or SignerType.EMAIL_TOKEN: prompt the client to sign

This will allow us to:

  • ✅ Create the Safe during signup (like passkeys)
  • ✅ Store the signer in the DB (like passkeys)
  • ✅ Use the real signer during operations (via client signature)