feat: WebAuthn client/server split for intent orchestration#340
Open
feat: WebAuthn client/server split for intent orchestration#340
Conversation
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>
Destiner
reviewed
Feb 10, 2026
| @@ -0,0 +1,34 @@ | |||
| import type { Hex, TypedDataDefinition } from 'viem' | |||
Contributor
There was a problem hiding this comment.
Imo this is a bit trivial and served best via docs/examples.
Destiner
reviewed
Feb 10, 2026
| 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( |
Contributor
There was a problem hiding this comment.
Maybe stupid idea, but what if we hash it ourselves and use sign under the hood?
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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!The SDK calls
account.sign()which is viem'sWebAuthnAccount— it internally callsnavigator.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 viemWebAuthnAccount-compatible object backed by async callbackssrc/client.ts- Browser-side helperssignWithPasskey()andsignTypedDataWithPasskey()for WebAuthn signing (exported via@rhinestone/sdk/client)src/accounts/signing/remoteWebAuthn.test.ts- Unit tests for remote account factoryModified Files:
src/execution/utils.ts- AddedassembleTransaction()to accept pre-computed WebAuthn signatures and pack them through the existing signing pipelinesrc/index.ts- ExposedassembleTransactiononRhinestoneAccount, exportedtoRemoteWebAuthnAccountand related typessrc/package.json- Added./clientexport entryPattern 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 }Code Example
Internal Code Path
After the callback returns, the SDK packs the signature:
Then wrapped per account type in
getEip1271Signature/getTypedDataPackedSignature: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 } endCode Example
How
assembleTransactionworks internallyassembleTransactionreuses the entire existing signing pipeline by creating temporary remote accounts that return pre-computed 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:#fff3e0Pattern A (callback):
toRemoteWebAuthnAccount→ pass tosignTransaction→ existing pipeline calls your callback when it needs a signature.Pattern B (explicit split):
getTransactionMessages→ send to browser → browser callssignTypedDataWithPasskey→ sends backWebAuthnSignResponse[]→ server callsassembleTransactionwhich internally creates temporary remote accounts and feeds them through the samesignIntentpipeline.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
toRemoteWebAuthnAccount()covering callback delegation and error casesChecklist