Passkey Kit

repository·main·Indexed 19 days ago

https://github.com/kalepail/passkey-kit

A TypeScript SDK (v0.14.0) for creating and managing Stellar smart-wallet accounts using WebAuthn passkeys. It handles the full lifecycle from passkey registration and deterministic wallet derivation to multi-signer transaction signing and fee-sponsored submission via a relayer. The kit includes specialized entry points for browser and server environments to ensure security, as well as helper libraries like pks-gen and sac-sdk for interacting with Soroban smart contracts via RPC.

Tokens
39.5K
Snippets
109
Records
156
Agent score
66%

What's inside passkey-kit

  1. Overview of passkey-kit relayer-proxy

    main
    The passkey-kit relayer-proxy is a fail-closed Cloudflare Worker designed to sit in front of OpenZeppelin Relayer Channels. It acts as a security gateway that allows browsers to send transaction material without needing a Channels API key directly. The Worker validates requests against strict security policies before lazily minting and storing a per-IP API key via the Channels service.
  2. Historical Smart Wallet Factory Interface

    main

    WARNING: This is a historical document and describes a superseded design.

    This record describes the original 'Protocol 21 era' proposal which used a factory-based design. The current shipped v1 contract does not use a factory; instead, wallets are deployed directly via __constructor(signer) and managed through add_signer, update_signer, remove_signer, upgrade, and get_signer.

    For the current, active interface, refer to contracts/smart-wallet-interface/src/, the root README.md (§ Contract interface), or CHANGELOG.md.

  3. How passkey-kit wallet addresses are derived

    main

    Passkey-kit wallet addresses are derived deterministically from the WebAuthn credential ID (keyId) alone. This allows the Mercury indexer and connectWallet to perform reverse lookups.

    The derivation formula is:

    contractId = sha256(XDR(HashIdPreimage::EnvelopeTypeContractId {
        networkId:  sha256(network passphrase),
        contractIdPreimage: ContractIdPreimageFromAddress {
            address: G-address of the canonical deployer keypair,
            salt:    sha256(keyId),
        },
    }))

    Key Constraints:

    • Canonical Deployer Keypair: Keypair.fromRawEd25519Seed(sha256(utf8("kalepail"))) $\rightarrow$ GC2C7AWLS2FMFTQAHW3IBUB4ZXVP4E37XNLEF2IK7IVXBB6CMEPCSXFO.
    • Immutability: The WASM hash is NOT part of the preimage. This means contract upgrades do not change the wallet address; the same keyId will always derive the same address across different contract versions.
    • Security Warning: Because the deployer is public and the executable is not bound by the preimage, anyone who knows a keyId can front-run the derived address with arbitrary code.

    Client-side Verification Requirements: To prevent front-running, clients MUST verify:

    1. The keyId is an actual stored signer (e.g., get_signer returns a valid value).
    2. (Recommended) The instance executable hash matches a known-good hash from the official deployment manifest.
  4. How signer discovery and reverse-lookup works in v1

    main

    In v1, live signer discovery is powered by Mercury's hosted, keyless passkey-indexer. This supports both networks (including testnet) and provides full history for both signer generations.

    • Enumerate Signers: Use server.getSigners(contractId) to retrieve an array of WalletSigner[] objects.
    • Reverse Lookup: Use server.getContractId({ keyId | publicKey | policy }) to find a contract ID associated with a specific key or policy.
    • Direct Access: You can also use MercuryIndexer.forNetwork(...) directly for indexing tasks.
    • Reconnects: The deterministic connectWallet() path remains available for common reconnection scenarios and does not require the indexer.
    // Enumerate all signers for a contract
    const signers = await server.getSigners(contractId);
    
    // Reverse lookup a contract ID from a public key
    const contractId = await server.getContractId({ publicKey: myPublicKey });
  5. Configure signer permissions with `SignerLimits`

    main

    The SignerLimits object defines what actions a specific signer is authorized to perform. It is a Map<string, SignerKey[] | undefined> | undefined.

    • undefined (the whole map): The signer is fully unlimited. They can authorize any action, including contract deployments and administrative functions.
    • Map present, but value is undefined: The signer can authorize any call to the specific contract key listed in the map, but requires no co-signers.
    • Map present, contract key maps to [keys]: The signer can only authorize calls to that contract if every listed key also provides a signature (required co-signers).
    IMPORTANT

    v1 breaking change: An empty map Map() now means no permissions (fail-closed). Previously, an empty map meant unlimited. Additionally, CreateContract* contexts require a fully unlimited (undefined) signer; you cannot grant deployment permissions via a limits entry.

    // This signer may only call a specific contract, and only alongside a passkey co-signer.
    const limits = new Map([["CONTRACT_ADDRESS", [SignerKey.Secp256r1(keyId)]]]);
  6. Behavioral changes in v1 (Security and Logic)

    main

    Several critical logic changes were introduced in v1 to improve security and consistency:

    Featurev1 Behavior
    Signer LimitsAn empty SignerLimits map is now fail-closed (no permissions) instead of unlimited.
    ExpirationSigner/signature expiration is now measured in UNIX timestamps (seconds) instead of ledger sequence.
    WebAuthn ChallengeChallenges are now random 32 bytes instead of a fixed string.
    Address AuthUses V2 (CAP-0071-02); the wallet address is part of the signed payload. The kit will refuse to sign entries that are not address-bound.
    Key UpdatesupdateSecp256r1(keyId, ...) no longer accepts a public key from the caller; the public key is re-read directly from the ledger for security.
    Ownership VerificationconnectWallet with a keyId not present on the wallet will now throw a WalletOwnershipError instead of trusting a looked-up address.
  7. Distinction between npm publishing and infrastructure deployment

    main

    Publishing npm packages is a separate concern from deploying the underlying infrastructure. The following components are NOT deployed via npm publish:

    • Contract WASM: Smart-wallet and sample-policy deployments are managed via the deployments manifest.
    • Mercury passkey-indexer: A keyless service that queries https://{testnet,mainnet}.mercurydata.app/rest/passkey-indexer.
    • Relayer-proxy worker: Deployed via Cloudflare using pnpm deploy or pnpm deploy:production.
    • Demo: Deployed to Cloudflare Pages using pnpm run deploy:demo or deploy:demo:prod.
  8. Understand Passkey Kit architecture and entry points

    main

    Passkey Kit is split into three distinct entry points to ensure security by preventing server-side secrets (like relayer keys) from being bundled into client-side code.

    ImportContentsWhere it runs
    passkey-kitPasskeyKit, signers, types, errors, validation, crypto helpers, the keyless MercuryIndexer + indexer typesBrowser or server
    passkey-kit/storageMemoryStorage, LocalStorageAdapter, IndexedDBStorageBrowser (persistence)
    passkey-kit/serverPasskeyServer, RelayerClientholds the relayer secretServer only

    Important: Never import from passkey-kit/server in browser-side code.

    // Browser
    import { PasskeyKit, PasskeySigner, Ed25519Signer, SACClient, SignerKey, SignerStore } from "passkey-kit";
    import { IndexedDBStorage } from "passkey-kit/storage";
    
    // Server ONLY
    import { PasskeyServer } from "passkey-kit/server";
  9. Understand the Passkey Kit demo architecture

    main

    The demo is structured to separate configuration, SDK logic, and UI components:

    • Configuration & Singletons (src/lib/config.ts): Contains public configuration and initializes singletons such as PasskeyKit, SACClient, the storage adapter, the relayer-proxy client, and the keyless MercuryIndexer.
    • SDK Flows (src/lib/actions.ts): Contains the logic for every flow that interacts with the SDK. UI components call these thin wrappers.
    • Reactive State (src/lib/state.svelte.ts): Manages the application state using Svelte 5 runes.
    • Relayer Communication (src/lib/{relayer-proxy,submit}.ts): Handles the seam between the browser and the relayer-worker.
    • UI Components (src/lib/components/): Organized into panels, with one panel dedicated to each specific concern.

    Security Model: Zero Secrets

    The demo follows a "zero secrets in the bundle" pattern. The client builds and signs transactions locally, but never holds sensitive keys. It interacts with external services as follows:

    • Submissions: Transactions are submitted through a server-side relayer-proxy worker (VITE_relayerProxyUrl) which holds the relayer key.
    • Discovery: Signers are discovered via Mercury's hosted passkey-indexer. This is a keyless process that does not require a proxy or a token.
    • Persistence: Passkey to wallet records are persisted using the SDK's LocalStorageAdapter rather than manual localStorage implementations.
  10. Handle errors and transaction results

    main

    The kit uses PasskeyKitError (or subclasses) for client-side errors. When branching on errors, use error.code or instanceof rather than message strings.

    Note on Submission: server.send and getTransaction do not throw for expected on-chain or relayer failures. Instead, they return a discriminated TransactionResult which you must check for success.

    const result = await server.send(tx);
    if (result.success) {
      // TransactionSuccess: { success: true, hash, ledger?, transactionId? }
      console.log(result.hash);
    } else {
      // TransactionFailure: { success: false, error: PasskeyKitError, hash? }
      if (result.error instanceof ContractError && result.error.contractErrorName === "SignerExpired") {
        // handle an on-chain contract failure by its decoded name
      }
    }
  11. How smart wallet authentication works via `__check_auth`

    main

    The __check_auth function is the core logic used to validate signatures for incoming requests. It follows these steps:

    1. Signer Lookup: It first attempts to find the public key (pk) in temporary storage (optimized for the common session signer case). If not found, it falls back to persistent storage.
    2. TTL Extension: Upon a successful lookup, the signer's Time-To-Live (TTL) is extended.
    3. Permission Check: If the signer is a temporary session signer, the function inspects the auth_contexts. It blocks the request if the signer attempts to call a protected contract function, with one exception: a session signer is permitted to call remove for its own specific id.
    4. Cryptographic Validation: Finally, it performs standard boilerplate signature and payload verification.

    This mechanism ensures that while session signers are convenient for daily use, the core administrative control of the wallet remains strictly with persistent admin signers.

  12. Understand wallet address derivation

    main

    Every wallet address is deterministically derived from its passkey credential ID (keyId) alone. This allows connectWallet and indexers to resolve a wallet without a lookup table.

    The derivation follows this logic:

    contractId = sha256(XDR(HashIdPreimage::EnvelopeTypeContractId { networkId: sha256(networkPassphrase), contractIdPreimage: ContractIdPreimageFromAddress { address: G-address of the canonical deployer keypair, salt: sha256(keyId) } }))

    Critical Implementation Details:

    • Canonical Deployer: The deployer is Keypair.fromRawEd25519Seed(sha256("kalepail")). It only pays fees and salts the deploy; it does not control the wallet.
    • Do not override deploySource: Changing the deployer changes the derived address and breaks keyId $\rightarrow$ wallet discovery.
    • Upgrades: The WASM hash is not part of the preimage, meaning an upgrade will never change the wallet's address.