Skip to content

feat: WebAuthn client/server split for intent orchestration#340

Open
zeroknots wants to merge 1 commit intomainfrom
feat/webauthn-client-server-split
Open

feat: WebAuthn client/server split for intent orchestration#340
zeroknots wants to merge 1 commit intomainfrom
feat/webauthn-client-server-split

Conversation

@zeroknots
Copy link
Member

@zeroknots zeroknots commented Feb 10, 2026

Description

Adds utilities to separate browser-side WebAuthn signing from server-side intent orchestration. The SDK can now run entirely on the server while delegating passkey signing to browser clients via custom transports (WebSocket, REST, tRPC, etc).

The Problem (Before)

sequenceDiagram
    participant Server as Server (Node.js)
    participant SDK as Rhinestone SDK
    participant Browser as Browser (WebAuthn)
    
    Server->>SDK: prepareTransaction(tx)
    SDK-->>Server: PreparedTransactionData
    
    Server->>SDK: signTransaction(prepared)
    Note over SDK: signIntent() → signIntentTypedData()
    Note over SDK: → signWithOwners() → signPasskey()
    SDK->>Browser: account.sign({ hash }) 💥
    Note over Browser: navigator.credentials.get()
    Note over SDK: FAILS - no browser on server!
Loading

The SDK calls account.sign() which is viem's WebAuthnAccount — it internally calls navigator.credentials.get(), a browser-only API. There's no way to intercept this.


Changes

New Files:

  • src/accounts/signing/remoteWebAuthn.ts - toRemoteWebAuthnAccount() factory creates a viem WebAuthnAccount-compatible object backed by async callbacks
  • src/client.ts - Browser-side helpers signWithPasskey() and signTypedDataWithPasskey() for WebAuthn signing (exported via @rhinestone/sdk/client)
  • src/accounts/signing/remoteWebAuthn.test.ts - Unit tests for remote account factory

Modified Files:

  • src/execution/utils.ts - Added assembleTransaction() to accept pre-computed WebAuthn signatures and pack them through the existing signing pipeline
  • src/index.ts - Exposed assembleTransaction on RhinestoneAccount, exported toRemoteWebAuthnAccount and related types
  • src/package.json - Added ./client export entry

Pattern A: Remote WebAuthn Account (Callback-Based)

Core mechanism. You provide async callbacks that shuttle the signing request to the browser via your own transport.

sequenceDiagram
    participant Server as Server (Node.js)
    participant SDK as Rhinestone SDK
    participant Transport as Your Transport<br/>(WebSocket/REST/tRPC)
    participant Browser as Browser (React app)
    
    Note over Server: Create remote account with callbacks
    Server->>SDK: toRemoteWebAuthnAccount({<br/>  credential,<br/>  sign: async ({ hash }) => ...,<br/>  signTypedData: async (params) => ...<br/>})
    SDK-->>Server: WebAuthnAccount (no browser needed)
    
    Server->>SDK: createAccount({ owners: { type: 'passkey', accounts: [remoteAccount] } })
    Server->>SDK: sendTransaction(tx) or signTransaction(prepared)
    
    Note over SDK: signIntent() → signIntentTypedData()
    Note over SDK: → signWithOwners() → signPasskey()
    SDK->>Server: calls your sign callback
    Server->>Transport: send hash to browser
    Transport->>Browser: "please sign this hash"
    Browser->>Browser: navigator.credentials.get()
    Browser->>Transport: { webauthn, signature }
    Transport->>Server: { webauthn, signature }
    Server-->>SDK: returns WebAuthnSignResponse
    Note over SDK: parseSignature → packPasskeySignature → EIP-1271 wrap
    SDK-->>Server: SignedTransactionData
    
    Server->>SDK: submitTransaction(signed)
    SDK-->>Server: TransactionResult { id }
Loading

Code Example

const remoteAccount = toRemoteWebAuthnAccount({
  credential: { id: 'abc123', publicKey: '0x...' },
  sign: async ({ hash }) => {
    // Your transport: send to browser via WebSocket, polling, etc.
    return await requestSignatureFromClient(hash)
  },
  signTypedData: async (params) => {
    return await requestSignatureFromClient(params)
  },
})

const rhinestoneAccount = await rhinestone.createAccount({
  owners: { type: 'passkey', accounts: [remoteAccount] },
})

// Works as before - signing delegates to your callback
await rhinestoneAccount.sendTransaction({ ... })

Internal Code Path

sendTransaction(tx)
  └→ signTransaction(prepared)                                // execution/utils.ts:262
       └→ signIntent(config, intentOp, targetChain, signers)  // :798
            ├→ getIntentMessages(config, intentOp)             // :917 (pure, extracts typed data)
            └→ for each origin element:
                 signIntentTypedData(...)                       // :965
                   │
                   ├─ if validator supports EIP-712:
                   │    getTypedDataPackedSignature(...)
                   │      └→ signTypedData(signers, ...)       // accounts/signing/typedData.ts
                   │           └→ signWithOwners(signers, ...) // accounts/signing/common.ts:215
                   │                └→ case 'passkey':
                   │                     account.signTypedData(params)  ← YOUR CALLBACK
                   │
                   └─ else (EIP-1271):
                        hash = hashTypedData(params)
                        getEip1271Signature(...)
                          └→ signMessage(signers, ...)         // accounts/signing/message.ts
                               └→ signWithOwners(signers, ...)
                                    └→ case 'passkey':
                                         account.sign({ hash })  ← YOUR CALLBACK

After the callback returns, the SDK packs the signature:

signWithOwners (common.ts:272-298)
  ├→ parseSignature(signature)         →  { r, s }       // passkeys.ts:34
  ├→ parsePublicKey(account.publicKey) →  { x, y }       // passkeys.ts:19
  ├→ generateCredentialId(x, y, addr)  →  bytes32        // passkeys.ts:48
  ├→ isRip7212SupportedNetwork(chain)  →  bool           // validators/core.ts:129
  └→ packPasskeySignature(credIds, usePrecompile, webAuthns)  // passkeys.ts:71
       → ABI encodes for on-chain verification

Then wrapped per account type in getEip1271Signature / getTypedDataPackedSignature:

packSafeSignature(...)     // Safe accounts
packNexusSignature(...)    // Nexus accounts
packKernelSignature(...)   // Kernel accounts
packStartaleSignature(...) // Startale accounts

Pattern B: Explicit Split (Serializable Payloads)

Full control over the server/client boundary with explicit steps.

sequenceDiagram
    participant Server as Server (Node.js)
    participant SDK as Rhinestone SDK
    participant Client as Browser (React)
    
    rect rgb(240, 248, 255)
    Note over Server,SDK: Step 1: Prepare (server)
    Server->>SDK: prepareTransaction(tx)
    SDK->>SDK: calls orchestrator API
    SDK-->>Server: PreparedTransactionData
    end
    
    rect rgb(240, 248, 255)
    Note over Server,SDK: Step 2: Extract messages (server)
    Server->>SDK: getTransactionMessages(prepared)
    SDK-->>Server: { origin: TypedDataDefinition[], destination: TypedDataDefinition }
    Server->>Client: send messages + credential over HTTP
    end
    
    rect rgb(255, 248, 240)
    Note over Client: Step 3: Sign on client (browser)
    Client->>Client: import { signTypedDataWithPasskey } from '@rhinestone/sdk/client'
    loop for each origin message
        Client->>Client: signTypedDataWithPasskey({ credential, typedData: msg })
        Note over Client: internally: toWebAuthnAccount() → account.signTypedData()
        Note over Client: → navigator.credentials.get() → biometric prompt
    end
    Client-->>Server: WebAuthnSignResponse[] over HTTP
    end
    
    rect rgb(240, 255, 240)
    Note over Server,SDK: Step 4: Assemble (server)
    Server->>SDK: assembleTransaction(prepared, { origin: sigs })
    Note over SDK: Creates temporary remote accounts<br/>with pre-computed signatures
    Note over SDK: Runs through existing signIntent pipeline
    Note over SDK: parseSignature → packPasskeySignature → EIP-1271 wrap
    SDK-->>Server: SignedTransactionData
    end
    
    rect rgb(240, 255, 240)
    Note over Server,SDK: Step 5: Submit (server)
    Server->>SDK: submitTransaction(signed)
    SDK->>SDK: POST to orchestrator API
    SDK-->>Server: TransactionResult { id }
    end
Loading

Code Example

// === SERVER: Step 1 - Prepare ===
const prepared = await rhinestoneAccount.prepareTransaction(transaction)
const messages = rhinestoneAccount.getTransactionMessages(prepared)
// Send `messages` + credential info to client

// === CLIENT (browser): Step 2 - Sign ===
import { signTypedDataWithPasskey } from '@rhinestone/sdk/client'

const originSigs = await Promise.all(
  messages.origin.map(typedData =>
    signTypedDataWithPasskey({ credential, typedData })
  )
)
// Send signatures back to server

// === SERVER: Step 3 - Assemble & Submit ===
const signed = rhinestoneAccount.assembleTransaction(prepared, {
  origin: originSigs,
})
await rhinestoneAccount.submitTransaction(signed)

How assembleTransaction works internally

assembleTransaction reuses the entire existing signing pipeline by creating temporary remote accounts that return pre-computed signatures:

assembleTransaction(config, prepared, rawSignatures)
  │
  ├→ Validates signers are passkey owners
  │
  ├→ Creates signature queue: [...rawSignatures.origin]
  │
  ├→ For each original passkey account, creates a remote account:
  │     toRemoteWebAuthnAccount({
  │       credential: { id: original.id, publicKey: original.publicKey },
  │       sign: async () => allSignatures[signatureIndex++],
  │       signTypedData: async () => allSignatures[signatureIndex++],
  │     })
  │
  └→ signIntent(config, intentOp, targetChain, remoteSigners, false)
       │  (THE EXACT SAME FUNCTION used by signTransaction)
       │
       └→ Each time the pipeline calls account.sign() or account.signTypedData(),
          the remote account returns the next pre-computed signature from the queue.
          No browser call. Just packing + wrapping of pre-provided signatures.

How the Pieces Connect

graph TB
    subgraph "Browser (client.ts)"
        SW[signWithPasskey]
        STDW[signTypedDataWithPasskey]
        VIEM_WA[viem toWebAuthnAccount]
        NAV[navigator.credentials.get]
        
        SW --> VIEM_WA
        STDW --> VIEM_WA
        VIEM_WA --> NAV
    end
    
    subgraph "Server (index.ts + execution/utils.ts)"
        RWA[toRemoteWebAuthnAccount]
        PT[prepareTransaction]
        GTM[getTransactionMessages]
        ST[signTransaction]
        AT[assembleTransaction]
        SUB[submitTransaction]
        SI[signIntent]
        
        ST --> SI
        AT -->|creates remote accounts| RWA
        AT -->|then calls| SI
    end
    
    subgraph "Shared Signing Pipeline (accounts/signing/)"
        SWO[signWithOwners]
        SP[signPasskey]
        PS[parseSignature]
        PPK[packPasskeySignature]
        
        SI --> SWO
        SWO -->|passkey case| SP
        SP -->|calls| ACC[account.sign / signTypedData]
        SP --> PS
        PS --> PPK
    end
    
    RWA -.->|produces| ACC
    VIEM_WA -.->|produces| ACC
    
    style RWA fill:#e1f5fe
    style AT fill:#e1f5fe
    style SW fill:#fff3e0
    style STDW fill:#fff3e0
Loading

Pattern A (callback): toRemoteWebAuthnAccount → pass to signTransaction → existing pipeline calls your callback when it needs a signature.

Pattern B (explicit split): getTransactionMessages → send to browser → browser calls signTypedDataWithPasskey → sends back WebAuthnSignResponse[] → server calls assembleTransaction which internally creates temporary remote accounts and feeds them through the same signIntent pipeline.

Both patterns converge on the same internal code path — signature packing, credential ID generation, RIP-7212 precompile detection, and EIP-1271 wrapping all happen identically.


Testing

  • 4 new unit tests for toRemoteWebAuthnAccount() covering callback delegation and error cases
  • All 105 existing unit tests pass
  • Build: ✓ typecheck ✓ tsc ✓ biome lint

Checklist

  • Changeset

Separates browser-side WebAuthn signing from server-side intent orchestration:
- toRemoteWebAuthnAccount() factory for callback-based remote signing
- signWithPasskey/signTypedDataWithPasskey client-side helpers
- assembleTransaction() for explicit server/client split pattern
- New @rhinestone/sdk/client export for browser utilities

This enables running the SDK server-side while delegating passkey signing
to browser clients via custom transport (WebSocket, REST, tRPC, etc).

Co-Authored-By: Claude Haiku 4.5 <noreply@anthropic.com>
@@ -0,0 +1,34 @@
import type { Hex, TypedDataDefinition } from 'viem'
Copy link
Contributor

Choose a reason for hiding this comment

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

Imo this is a bit trivial and served best via docs/examples.

async signMessage() {
// For remote accounts, the sign callback should handle message hashing
// The SDK only uses sign({ hash }) and signTypedData() for passkey signing
throw new Error(
Copy link
Contributor

Choose a reason for hiding this comment

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

Maybe stupid idea, but what if we hash it ourselves and use sign under the hood?

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants