Skip to content

Offline-derivable PKP addresses (remove the mint round-trip for ChainSecured accounts) #574

Description

@clawdbot-glitch003

Summary

Minting a new PKP requires a round-trip to lit-api-server to learn the wallet address, because address derivation depends on the TEE-sealed app master key. This is the only step that prevents a ChainSecured account from doing all of its account management (create PKPs, add groups, set usage keys) purely on-chain and only talking to the API when it's time to run an action or pay. This issue proposes making PKP addresses offline-derivable by clients so that step disappears.

How derivation works today

  • create_new_wallet (src/core/account_management.rs:83-101) generates a random derivation path (utils/mod.rs generate_unique_derivation_path, no account input), calls get_client_key(path), and derives the address from the resulting secp256k1 secret.
  • get_client_key (src/dstack/v1/mod.rs) = dstack get_key(path, "client") then keccak256(secret).
  • dstack get_key (guest-agent rpc_service.rsra-tls/src/kdf.rs) = HKDF-SHA256(ikm = app_k256_key, salt = "RATLS", info = [path]). The response returns the private key + a signature_chain; there is no usable parent public key.
  • Runtime re-derivation reads the path from on-chain AccountConfig.getWalletDerivation(accountHash, pkpAddress) and re-runs get_client_key.

Why offline derivation is impossible today: the scheme is a symmetric KDF — priv = keccak256(HKDF(app_master, path)). Each key is independent; there is no algebraic parent→child public-key relationship (unlike BIP32 non-hardened derivation), so you cannot compute any child address without the TEE-only master. Hence the mandatory mint round-trip.

Two related observations from the same code:

  • Derivation paths are app-scoped, not account-scoped (generate_unique_derivation_path ignores the account).
  • registerWalletDerivation only guards "PKP already registered" per account, and getWalletDerivation is keyed by (accountHash, address) — so two different accounts can register the same (address, path) and both gain use/decrypt rights. (Pre-existing; the proposal below closes this as a side effect.)

Proposal: account "xpub" + additive (tweaked) child derivation

Introduce a BIP32-non-hardened-style relationship so clients derive PKP addresses offline from a published, account-scoped parent public key, while private keys never leave the TEE. Implementable entirely in lit-api-server (it runs in the enclave and holds the app master) — no dstack change required.

Let G = secp256k1 generator, n = curve order, = concatenation.

  1. Account parent key (derived once, in-TEE): m_acct = keccak256(get_client_key("v1/account_root/" ‖ accountHash)); M_acct = m_acct·G (the "account xpub"). m_acct stays in the TEE; only M_acct is exposed.
  2. Publish M_acct via a read endpoint (returning it plus the dstack signature_chain attestation) and/or as a field on the account in AccountConfig (on-chain is nicest — then zero API calls are needed).
  3. Child wallet at client-chosen index i (offline): t = keccak256(M_acct ‖ accountHash ‖ i) mod n; P_i = M_acct + t·Gaddress = keccak256(P_i)[12:]. Any secp256k1 lib computes this with no enclave call.
  4. Register + use: client picks i, computes the address offline, and in a single on-chain UserOp calls registerWalletDerivation(accountHash, address, i, …) (+ addGroup, etc.). At sign/decrypt time the TEE recomputes p_i = (m_acct + t) mod n, checks p_i·G matches the registered address, and proceeds.

Properties

  • Offline addresses: anyone with M_acct computes child addresses; deriving child private keys still needs m_acct (TEE-only).
  • Account-scoped by construction: the tweak binds accountHash, so a wallet derived for account A can't be re-derived/claimed under account B — closing the cross-account registerWalletDerivation gap above.
  • No dstack change: additive derivation runs in lit-api-server on top of one existing get_client_key call per account (cache in-enclave).
  • Backward compatible: keep legacy random-path derivation for existing PKPs; tag new records as tweaked.

Security notes

  • Classic BIP32 caveat: a leaked child private key + M_acct reveals m_acct and all siblings. Acceptable here because child private keys never leave the enclave — but keep m_acct per-account so blast radius is one account, and state this in docs.
  • Attest M_acct with the dstack signature_chain (already returned by get_key) so clients/relayers can verify the parent pubkey before trusting offline-derived addresses.

Alternatives (weaker; for completeness)

  • B. Attested derive-only endpoint (no signature): expose create_new_wallet's derivation as POST /core/v1/derive_wallet {derivationPath} -> {address}, admin/rate-limit gated, writing nothing. Removes the signature but not the round-trip. Small, ships quickly; a fine stopgap and useful for API-mode clients.
  • C. dstack-level hierarchical derivation: push BIP32/xpub support upstream into dstack's key provider. Cleanest layering but a dependency change with a longer timeline; the in-Chipotle additive scheme needs nothing from dstack.

Recommendation: ship B if a quick win is wanted; pursue A as the real fix (offline derivation + account binding + zero API calls for ChainSecured account management).

Touch points for the proposal

  • src/utils/mod.rs — derivation-path/index helpers.
  • src/core/account_management.rs:83-101create_new_wallet (add tweaked path; keep legacy).
  • src/dstack/v1/mod.rs — add account_parent_pubkey(accountHash).
  • src/core/mod.rs + src/actions/client/op_code_helpers/{private_keys,encryption}.rs — runtime re-derivation for tweaked records + address check.
  • blockchain/lit_node_express/contracts/AccountConfigFacets/{WritesFacet,ViewsFacet}.sol — optional: store/serve M_acct; bind registerWalletDerivation to the tweak index; account-scoping fix.
  • Tests: offline-derived address (JS/Rust) == in-TEE address; cross-account re-registration rejected; legacy random-path wallets still sign/decrypt.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Fields

    No fields configured for issues without a type.

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions